Compare commits

...
2 Commits
Author SHA1 Message Date
phenom b57d2b2839 Release Healer Man 0.1.16 2026-09-05 20:42:38 -04:00
phenom f49e54f35f Release Healer Man 0.1.15 2026-09-05 15:33:06 -04:00
124 changed files with 7527 additions and 123 deletions

No files matched your search

+31
View File
@@ -1654,3 +1654,34 @@ Vite's existing large-chunk advisory remains informational.
corrected production build completed with 923 modules transformed. KTX2 and
environment-performance audits remained green, and the release-candidate
check passed.
# 2026-09-05 — specialization-aware heirlooms and endgame loot
- Activated the legacy level-10 Priest `Rolecheck`. The mandatory Class Hall
picker appeared before the main menu, offered Discipline, Holy, and Shadow,
and presented Cancel, Keep talents, and Reset talents after choosing Holy.
Keeping talents returned to the main menu with the specialization saved.
- Opened Wailing Caverns from Dungeon Finder. Role selection was restricted to
Healer, matching the saved Holy specialization; no Tank or DPS override was
available.
- Reviewed the roster preview and a live Wailing Caverns combat scene. The
player body, staff, and all four AI party bodies/equipment silhouettes were
visible and correctly anchored without obscuring the HUD or playfield.
- Opened Inventory & Equipment in the dungeon. The player had zero earned
items but every applicable slot resolved to a level-10 Priest healer
heirloom and reported average item level 10. Morvane, the Blood Death Knight
companion, showed a complete tank heirloom loadout with the intended
two-handed weapon layout and empty conflicting slots.
- Browser logs contained no uncaught application errors. Only the existing
third-party initialization deprecation warning appeared.
- Automated coverage validates every classic and CoA specialization, Feral
Bear/Cat separation, RoM-derived profiles, player and party scaling caps,
same-level catalog dominance at levels 1/40/79, real item-level-85 upgrade
availability for every gear archetype, two-hand replacement scoring,
fallback reappearance, percentage-preserving stat refreshes, specialization
persistence and talent reset behavior, and the item-level-255 save limit.
- Verification: the complete Vitest suite passed all 928 cases across 171
files, including all 43 avatar cases. All 15 server persistence/API tests
passed. The production build completed with 925 modules transformed, KTX2
and environment-performance audits remained green, and the release-candidate
check passed. The existing large-chunk advisory remains informational.
+17 -6
View File
@@ -103,12 +103,23 @@ validates the relational data and ships both a browser-safe JSON catalog and
for a future loot-browser screen.
Every Wailing Caverns boss rolls one item when it dies. The item instance
captures the player's level at the killing blow, remains on the corpse until
looted, and is then saved in that character's inventory. The SQL loot source
contains the original 3.3.5a attributes, ratings, armor, block, weapon damage,
weapon speed, regeneration, and spell-power values for each item. A scaled
drop preserves that original stat distribution instead of replacing it with a
generic item-power budget.
captures its item level at the killing blow, remains on the corpse until
looted, and is then saved in that character's inventory. Drops use character
level through 79; level-80 drops begin the first endgame tier at item level 85.
The SQL loot source contains the original 3.3.5a attributes, ratings, armor,
block, weapon damage, weapon speed, regeneration, and spell-power values for
each item. A scaled drop preserves that original stat distribution instead of
replacing it with a generic item-power budget.
Every WoW and Conquest of Azeroth character has a saved specialization that
locks its activity role and selects a complete specialization-aware heirloom
loadout. Runes of Magic derives the same profile from its active primary class
and selected role. Virtual heirlooms fill every applicable unoccupied slot,
rescale through item level 79 for players and 80 for AI party members, never
enter inventory, and automatically return when earned equipment is removed.
During leveling their score is normalized above compatible same-level catalog
drops. At level 80 the item-level-85 boss tier contains real upgrades, which
the equipment and loot interfaces highlight without equipping automatically.
The equipment runtime uses the Wrath primary attributes and itemizable combat
stats. Strength, Agility, Stamina, Intellect, Spirit, Attack Power, Spell
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"slug": "blades-edge-arena",
"title": "Blade's Edge Arena",
"mapId": 562,
"lfgDungeonIds": [],
"expansion": "the-burning-crusade",
"clientBuild": "3.3.5a",
"mapDirectory": "World/Maps/bladesedgearena",
"environmentMode": "global-wmo",
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
"exceptions": []
}
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"slug": "dalaran-sewers",
"title": "Dalaran Sewers",
"mapId": 617,
"lfgDungeonIds": [],
"expansion": "wrath-of-the-lich-king",
"clientBuild": "3.3.5a",
"mapDirectory": "World/Maps/DalaranArena",
"environmentMode": "global-wmo",
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
"exceptions": []
}
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"slug": "nagrand-arena",
"title": "Nagrand Arena",
"mapId": 559,
"lfgDungeonIds": [],
"expansion": "the-burning-crusade",
"clientBuild": "3.3.5a",
"mapDirectory": "World/Maps/PVPZone01",
"environmentMode": "global-wmo",
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
"exceptions": []
}
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"slug": "ring-of-valor",
"title": "Ring of Valor",
"mapId": 618,
"lfgDungeonIds": [],
"expansion": "wrath-of-the-lich-king",
"clientBuild": "3.3.5a",
"mapDirectory": "World/Maps/OrgrimmarArena",
"environmentMode": "global-wmo",
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
"exceptions": []
}
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"slug": "ruins-of-lordaeron",
"title": "Ruins of Lordaeron",
"mapId": 572,
"lfgDungeonIds": [],
"expansion": "the-burning-crusade",
"clientBuild": "3.3.5a",
"mapDirectory": "World/Maps/PVPLordaeron",
"environmentMode": "global-wmo",
"difficultyVariants": [{"id": "rated", "difficulty": "normal", "spawnMask": 1, "levelRange": [80, 80]}],
"clientSource": {"rootEnvironmentVariable": "WOW_CLIENT_ROOT", "relativeDefault": "../wow335a"},
"serverSource": {"rootEnvironmentVariable": "WOW_SERVER_DATA", "adapter": "normalized-json", "readOnly": true},
"extraction": {"rootWmo": "World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena.wmo", "doodadSets": ["Set_$DefaultGlobal"]},
"navigation": {"cellSize": 0.2, "cellHeight": 0.1, "agentHeight": 1.8, "agentRadius": 0.36, "agentMaxClimb": 0.5, "agentMaxSlope": 50},
"review": {"entrance": "required", "bossOrder": "not-applicable", "optionalEncounters": "not-applicable", "presentation": "required", "offMeshLinks": "required"},
"exceptions": []
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.14",
"version": "0.1.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.14",
"version": "0.1.16",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+4 -2
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.14",
"version": "0.1.16",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -63,6 +63,8 @@
"dungeon:navmesh": "node scripts/dungeon-pipeline/cli.mjs navmesh",
"dungeon:navmesh:conversion": "node scripts/dungeon-pipeline/cli.mjs navmesh-conversion",
"dungeon:validate": "node scripts/dungeon-pipeline/cli.mjs validate",
"arena:assets:build": "node scripts/arena-pipeline/build-wmo-arenas.mjs && node scripts/arena-pipeline/build-custom-arenas.mjs && node scripts/arena-pipeline/prepare-warnorken.mjs",
"arena:assets:validate": "node scripts/arena-pipeline/validate-arenas.mjs",
"dungeon:enable": "node scripts/dungeon-pipeline/cli.mjs enable",
"runewaker:forsaken": "node \"%INIT_CWD%\\scripts\\runewaker-pipeline\\build-forsaken-abbey.mjs\"",
"runewaker:forsaken:actors": "node scripts/runewaker-pipeline/build-forsaken-population.mjs --actors-only",
@@ -98,7 +100,7 @@
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
"prebuild": "npm run loot:generate && npm run web:prepare && npm run assets:ktx2:audit && npm run assets:performance:audit",
"prebuild": "npm run loot:generate && npm run web:prepare && npm run arena:assets:validate && 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",
@@ -0,0 +1,127 @@
{
"build": {
"attempts": [
{
"failures": [],
"tileCount": 1,
"tileSize": null,
"valid": true
}
],
"gridCells": 258876,
"gridHeight": 564,
"gridWidth": 459,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
"partition": "watershed",
"populatedTileCount": 1,
"tileColumns": 1,
"tileCount": 1,
"tileRows": 1,
"tileSize": null
},
"dungeonId": "ashamanes-fall",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
16.435361862182617,
-2.676706314086914,
4.506431579589844
],
[
65.03536224365234,
26.523292541503906,
43.1064338684082
]
],
"boundsTolerance": 0.4,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 353,
"sourceVertices": 1059,
"triangles": 353,
"vertices": 1059
},
"input": {
"bounds": [
[
-6.364639759063721,
-19.976707458496094,
-59.093570709228516
],
[
85.27696990966797,
36.782005310058594,
53.62468338012695
]
],
"degenerateTriangleCount": 0,
"invalidIndexCount": 0,
"meshCount": 1,
"nonFiniteCoordinateCount": 0,
"triangleCount": 2783,
"vertexCount": 8349
},
"inputChunkChecksums": {
"public/assets/game/arenas/ashamanes-fall/navigation-collision.glb": "cdc39b903da6a6f9f21bd9e5e0b2ca61e76b09e13575b02349ca92d68815e5a8"
},
"inputChunks": [
"public/assets/game/arenas/ashamanes-fall/navigation-collision.glb"
],
"output": "public/assets/game/arenas/ashamanes-fall/navigation-raw.glb",
"schemaVersion": 1,
"settings": {
"agentHeight": 1.8,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"agentRadius": 0.36,
"cellHeight": 0.1,
"cellSize": 0.2
},
"source": {
"adtFiles": [
"valsharaharena_20_24.adt",
"valsharaharena_20_25.adt",
"valsharaharena_20_26.adt",
"valsharaharena_21_24.adt",
"valsharaharena_21_25.adt",
"valsharaharena_21_26.adt",
"valsharaharena_22_24.adt",
"valsharaharena_22_25.adt",
"valsharaharena_22_26.adt"
],
"gameplayBounds": {
"max": [
58,
16,
36
],
"min": [
14,
-4,
2
]
},
"kind": "adt-placed-wmo-repair",
"legacyCollision": "public/assets/game/manastorm/1785-valsharaharena/collision.glb",
"placedWmos": [
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/VALSHARAH/7VS_CAVEMICRO01.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RUINPIECE06.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_TOWER01_NIGHTMARE.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01_NIGHTMARE.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RUINPIECE06.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_BRIDGESHORT.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/DUNGEON/PVP/CUSTOM_VALSHARAH_ARENA.WMO"
]
}
}
@@ -0,0 +1,99 @@
{
"schemaVersion": 1,
"id": "ashamanes-fall",
"title": "Ashamane's Fall",
"status": "generated-review-required",
"source": {
"mapDirectory": "patch-WB2/world/maps/valsharaharena",
"adtFiles": [
"valsharaharena_20_24.adt",
"valsharaharena_20_25.adt",
"valsharaharena_20_26.adt",
"valsharaharena_21_24.adt",
"valsharaharena_21_25.adt",
"valsharaharena_21_26.adt",
"valsharaharena_22_24.adt",
"valsharaharena_22_25.adt",
"valsharaharena_22_26.adt"
],
"placementCount": 16,
"placedWmos": [
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RETAINERCURVED02.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/VALSHARAH/7VS_CAVEMICRO01.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RUINPIECE06.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_TOWER01_NIGHTMARE.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_DRUID_BRIDGE01_NIGHTMARE.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_RUINPIECE06.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/NIGHTELF/7NE_NIGHTELF_BRIDGESHORT.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/DUNGEON/PVP/CUSTOM_VALSHARAH_ARENA.WMO"
]
},
"coordinateSystem": {
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
"rebasedOrigin": [
6.0437164306640625,
-37.49217987060547,
30.740463256835938
],
"alignmentOffset": [
34.174934,
-32.347685,
20.31486
],
"alignment": "primary WMO matched to the existing HealerMan stage coordinate frame"
},
"bounds": {
"min": [
-289.133544921875,
-32.347686767578125,
-200.8459930419922
],
"max": [
357.4833984375,
316.2064514160156,
241.47572326660156
]
},
"geometry": {
"groups": 18,
"triangles": 82815,
"collisionTriangles": 82815,
"collisionChunks": 1
},
"navigation": {
"triangles": 209,
"vertices": 627,
"sourceComponents": 20,
"spawnDistances": [
0.5941920148506428,
0.0212655626862784
],
"rawTriangles": 353,
"report": "recast-report.json"
},
"assets": [
{
"file": "visual.glb",
"bytes": 2611788,
"sha256": "25e8a0d63d420559a6bc965dc2b930039d951de8cdc189d785a00edc3610b112"
},
{
"file": "collision.glb",
"bytes": 1805536,
"sha256": "48f45c434687f07969a618e1251e05e8672061d16a1f3a88338dbc667e33e78e"
},
{
"file": "navigation.glb",
"bytes": 10936,
"sha256": "ac69ef1e845f632576598a8b2a5d0bbfa8086b21ed5d7fd17af06d5a565a5248"
}
]
}
Binary file not shown.
@@ -0,0 +1,114 @@
{
"build": {
"attempts": [
{
"failures": [],
"tileCount": 1,
"tileSize": null,
"valid": true
}
],
"gridCells": 2074478,
"gridHeight": 994,
"gridWidth": 2087,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
"partition": "watershed",
"populatedTileCount": 1,
"tileColumns": 1,
"tileCount": 1,
"tileRows": 1,
"tileSize": null
},
"dungeonId": "black-rook-hold-arena",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
-92.08255767822266,
-16.14961814880371,
-279.4311218261719
],
[
321.9174499511719,
37.25038528442383,
-82.83113098144531
]
],
"boundsTolerance": 0.4,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 7170,
"sourceVertices": 21510,
"triangles": 7170,
"vertices": 21510
},
"input": {
"bounds": [
[
-93.08255767822266,
-39.549617767333984,
-280.0311279296875
],
[
324.1624450683594,
69.1881103515625,
-81.26593780517578
]
],
"degenerateTriangleCount": 0,
"invalidIndexCount": 0,
"meshCount": 1,
"nonFiniteCoordinateCount": 0,
"triangleCount": 49821,
"vertexCount": 149463
},
"inputChunkChecksums": {
"public/assets/game/arenas/black-rook-hold-arena/navigation-collision.glb": "285bdce7d5b5085eef7d25f82a5b6a5b380605a310c539e8e94984182c835749"
},
"inputChunks": [
"public/assets/game/arenas/black-rook-hold-arena/navigation-collision.glb"
],
"output": "public/assets/game/arenas/black-rook-hold-arena/navigation-raw.glb",
"schemaVersion": 1,
"settings": {
"agentHeight": 1.8,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"agentRadius": 0.36,
"cellHeight": 0.1,
"cellSize": 0.2
},
"source": {
"adtFiles": [
"blackrookholdarena_28_28.adt",
"blackrookholdarena_28_29.adt",
"blackrookholdarena_28_30.adt",
"blackrookholdarena_29_28.adt",
"blackrookholdarena_29_29.adt",
"blackrookholdarena_29_30.adt",
"blackrookholdarena_30_28.adt",
"blackrookholdarena_30_29.adt",
"blackrookholdarena_30_30.adt"
],
"gameplayBounds": {
"max": [
312,
30,
-110
],
"min": [
-82,
-10,
-242
]
},
"kind": "adt-placed-wmo-repair",
"legacyCollision": "public/assets/game/manastorm/1784-blackrookholdarena/collision.glb",
"placedWmos": [
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/VALSHARAH/7VS_BLACKROOKHOLD_MICRO01.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/7VS_BLACKROOKHOLD_ARENA.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/BLACKROOKHOLD_ARENA_PLATFORMTEST.WMO"
]
}
}
@@ -0,0 +1,101 @@
{
"schemaVersion": 1,
"id": "black-rook-hold-arena",
"title": "Black Rook Hold Arena",
"status": "generated-review-required",
"source": {
"mapDirectory": "patch-WB1/world/maps/blackrookholdarena",
"adtFiles": [
"blackrookholdarena_28_28.adt",
"blackrookholdarena_28_29.adt",
"blackrookholdarena_28_30.adt",
"blackrookholdarena_29_28.adt",
"blackrookholdarena_29_29.adt",
"blackrookholdarena_29_30.adt",
"blackrookholdarena_30_28.adt",
"blackrookholdarena_30_29.adt",
"blackrookholdarena_30_30.adt"
],
"placementCount": 3,
"placedWmos": [
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/BROKENISLES/VALSHARAH/7VS_BLACKROOKHOLD_MICRO01.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/7VS_BLACKROOKHOLD_ARENA.WMO",
"../LadiksMPQEditor/patch-WC1/WORLD/WMO/PVP/BUILDINGS/BLACKROOKHOLD/BLACKROOKHOLD_ARENA_PLATFORMTEST.WMO"
]
},
"coordinateSystem": {
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
"rebasedOrigin": [
-117.73179244995117,
-104.28124237060547,
60.68927764892578
],
"alignmentOffset": [
117.542488,
-102.29949,
-224.463489
],
"alignment": "primary WMO matched to the existing HealerMan stage coordinate frame"
},
"bounds": {
"min": [
-110.3111343383789,
-102.29949188232422,
-519.4838256835938
],
"max": [
345.3961181640625,
410.11322021484375,
70.55683898925781
]
},
"geometry": {
"groups": 20,
"triangles": 298817,
"collisionTriangles": 298817,
"collisionChunks": 4
},
"navigation": {
"triangles": 4948,
"vertices": 14844,
"sourceComponents": 805,
"spawnDistances": [
0.009180110009213026,
0.01568599673120942
],
"rawTriangles": 7170,
"report": "recast-report.json"
},
"assets": [
{
"file": "visual.glb",
"bytes": 9908648,
"sha256": "622c0832eb5a3bca85837de418b02be6dc5dad54db3b05e69e740d93c870e319"
},
{
"file": "collision.glb",
"bytes": 2059780,
"sha256": "27a698c59e08744bd7bf6cfe9585f643a28aea45850da85020e3b4bac45ce92f"
},
{
"file": "collision-001.glb",
"bytes": 2443032,
"sha256": "914702646d8a6a657d677bd9f69b1b0aea13f4033952c143ff85d8932127a82f"
},
{
"file": "collision-002.glb",
"bytes": 2377504,
"sha256": "33622cbbc5e82cebee74049a53cd9fbb4e1ca8196322697f751916a3dd9d30ec"
},
{
"file": "collision-003.glb",
"bytes": 916280,
"sha256": "2f405e27efcf4e7b4d41d2286293dcbe700d854edf4d5aa276bf5b0523e9748a"
},
{
"file": "navigation.glb",
"bytes": 238420,
"sha256": "e463a0220842a4793b2150e95f4fd704f575def288813c781792b021afe8d3f0"
}
]
}
@@ -0,0 +1,89 @@
{
"build": {
"attempts": [
{
"failures": [],
"tileCount": 1,
"tileSize": null,
"valid": true
}
],
"gridCells": 652881,
"gridHeight": 849,
"gridWidth": 769,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
"partition": "watershed",
"populatedTileCount": 1,
"tileColumns": 1,
"tileCount": 1,
"tileRows": 1,
"tileSize": null
},
"dungeonId": "blades-edge-arena",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
-75.85150146484375,
6.099999904632568,
-80.292236328125
],
[
76.14849853515625,
82.0999984741211,
80.3077621459961
]
],
"boundsTolerance": 0.4,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 3932,
"sourceVertices": 11796,
"triangles": 3932,
"vertices": 11796
},
"input": {
"bounds": [
[
-76.85150146484375,
0,
-84.8922348022461
],
[
76.85150146484375,
82.12013244628906,
84.8922348022461
]
],
"degenerateTriangleCount": 0,
"invalidIndexCount": 0,
"meshCount": 1,
"nonFiniteCoordinateCount": 0,
"triangleCount": 7327,
"vertexCount": 7086
},
"inputChunkChecksums": {
"public/assets/game/arenas/blades-edge-arena/collision.glb": "96bb151cc43fcac887cee1e573878221e14d4690e023bc4cf7b5b40c2788333a"
},
"inputChunks": [
"public/assets/game/arenas/blades-edge-arena/collision.glb"
],
"output": "public/assets/game/arenas/blades-edge-arena/navigation-raw.glb",
"schemaVersion": 1,
"settings": {
"agentHeight": 1.8,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"agentRadius": 0.36,
"cellHeight": 0.1,
"cellSize": 0.2
},
"source": {
"files": [
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium_000.wmo"
],
"kind": "direct-wmo-parser"
}
}
@@ -0,0 +1,92 @@
{
"schemaVersion": 1,
"id": "blades-edge-arena",
"title": "Blade's Edge Arena",
"status": "generated-review-required",
"source": {
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium.wmo",
"groupCount": 1
},
"coordinateSystem": {
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
"rebasedOrigin": [
-0.32387542724609375,
-3.3863677978515625,
-0.1770477294921875
]
},
"sourceBounds": {
"min": [
-77.17537689208984,
-3.3863677978515625,
-85.06928253173828
],
"max": [
76.52762603759766,
78.7337646484375,
84.7151870727539
]
},
"bounds": {
"min": [
-76.85150146484375,
0,
-84.8922348022461
],
"max": [
76.85150146484375,
82.12013244628906,
84.8922348022461
]
},
"geometry": {
"groups": 1,
"vertices": 7086,
"triangles": 7327,
"collisionTriangles": 7327,
"triangleFlags": [
{
"group": "group-000",
"counts": {
"8": 680,
"36": 616,
"38": 137,
"72": 2729,
"100": 2922,
"102": 243
}
}
]
},
"navigation": {
"triangles": 465,
"vertices": 1395,
"sourceComponents": 134,
"spawnDistances": [
0.0026893801684426334,
0.004853620635976984
],
"rawTriangles": 3932,
"report": "recast-report.json"
},
"assets": [
{
"role": "visual",
"file": "visual.glb",
"bytes": 259116,
"sha256": "5dc8c6662bccf04892b059c819de91b1134cce6011a1f707c5ee9645491e5279"
},
{
"role": "collision",
"file": "collision.glb",
"bytes": 173784,
"sha256": "96bb151cc43fcac887cee1e573878221e14d4690e023bc4cf7b5b40c2788333a"
},
{
"role": "navigation",
"file": "navigation.glb",
"bytes": 23228,
"sha256": "d99822c69df3f6363c9fab1c6c67c70c3afa4e94ed0e66fa992adf8b6724cf2b"
}
]
}
@@ -0,0 +1,90 @@
{
"build": {
"attempts": [
{
"failures": [],
"tileCount": 1,
"tileSize": null,
"valid": true
}
],
"gridCells": 529759,
"gridHeight": 713,
"gridWidth": 743,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
"partition": "watershed",
"populatedTileCount": 1,
"tileColumns": 1,
"tileCount": 1,
"tileRows": 1,
"tileSize": null
},
"dungeonId": "dalaran-sewers",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
-73.60513305664062,
0.30000001192092896,
-69.85077667236328
],
[
73.5948715209961,
79,
70.14923095703125
]
],
"boundsTolerance": 0.4,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 4054,
"sourceVertices": 12162,
"triangles": 4054,
"vertices": 12162
},
"input": {
"bounds": [
[
-74.20513153076172,
0,
-71.25077819824219
],
[
74.20513153076172,
92.40137481689453,
71.25077819824219
]
],
"degenerateTriangleCount": 0,
"invalidIndexCount": 0,
"meshCount": 2,
"nonFiniteCoordinateCount": 0,
"triangleCount": 15469,
"vertexCount": 13094
},
"inputChunkChecksums": {
"public/assets/game/arenas/dalaran-sewers/collision.glb": "88c889838b76365ee0541dcce567151419b3310a6dc1252934ffb997f31215bd"
},
"inputChunks": [
"public/assets/game/arenas/dalaran-sewers/collision.glb"
],
"output": "public/assets/game/arenas/dalaran-sewers/navigation-raw.glb",
"schemaVersion": 1,
"settings": {
"agentHeight": 1.8,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"agentRadius": 0.36,
"cellHeight": 0.1,
"cellSize": 0.2
},
"source": {
"files": [
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena_000.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena_001.wmo"
],
"kind": "direct-wmo-parser"
}
}
@@ -0,0 +1,105 @@
{
"schemaVersion": 1,
"id": "dalaran-sewers",
"title": "Dalaran Sewers",
"status": "generated-review-required",
"source": {
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena.wmo",
"groupCount": 2
},
"coordinateSystem": {
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
"rebasedOrigin": [
-9.411468505859375,
-16.876672744750977,
10.746021270751953
]
},
"sourceBounds": {
"min": [
-83.6166000366211,
-16.876672744750977,
-60.5047607421875
],
"max": [
64.79366302490234,
75.52470397949219,
81.9968032836914
]
},
"bounds": {
"min": [
-74.20513153076172,
0,
-71.25077819824219
],
"max": [
74.20513153076172,
92.40137481689453,
71.25077819824219
]
},
"geometry": {
"groups": 2,
"vertices": 13094,
"triangles": 15469,
"collisionTriangles": 15469,
"triangleFlags": [
{
"group": "group-000",
"counts": {
"8": 85,
"32": 545,
"36": 573,
"72": 40,
"96": 2835,
"100": 98
}
},
{
"group": "group-001",
"counts": {
"8": 165,
"10": 480,
"32": 766,
"36": 2362,
"72": 136,
"74": 416,
"96": 2053,
"100": 4915
}
}
]
},
"navigation": {
"triangles": 458,
"vertices": 1374,
"sourceComponents": 104,
"spawnDistances": [
0.004929398584727517,
0.0049300317713031425
],
"rawTriangles": 4054,
"report": "recast-report.json"
},
"assets": [
{
"role": "visual",
"file": "visual.glb",
"bytes": 501848,
"sha256": "91b37f5df3c5c0d2ecad9e1096177abfe8eb36acc384df36a949ce246a0155ba"
},
{
"role": "collision",
"file": "collision.glb",
"bytes": 344140,
"sha256": "88c889838b76365ee0541dcce567151419b3310a6dc1252934ffb997f31215bd"
},
{
"role": "navigation",
"file": "navigation.glb",
"bytes": 22896,
"sha256": "2e1db39fe16bd3d4a747710e1da774d841bc9cd9ada446c666a1d95a4e1aafb2"
}
]
}
Binary file not shown.
@@ -0,0 +1,89 @@
{
"build": {
"attempts": [
{
"failures": [],
"tileCount": 1,
"tileSize": null,
"valid": true
}
],
"gridCells": 605259,
"gridHeight": 783,
"gridWidth": 773,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
"partition": "watershed",
"populatedTileCount": 1,
"tileColumns": 1,
"tileCount": 1,
"tileRows": 1,
"tileSize": null
},
"dungeonId": "nagrand-arena",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
-75.41183471679688,
1.2000000476837158,
-74.84174346923828
],
[
75.9881591796875,
72.80000305175781,
65.15826416015625
]
],
"boundsTolerance": 0.4,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 3536,
"sourceVertices": 10608,
"triangles": 3536,
"vertices": 10608
},
"input": {
"bounds": [
[
-77.21183776855469,
0,
-78.24174499511719
],
[
77.21183776855469,
82.11756134033203,
78.24174499511719
]
],
"degenerateTriangleCount": 0,
"invalidIndexCount": 0,
"meshCount": 1,
"nonFiniteCoordinateCount": 0,
"triangleCount": 7727,
"vertexCount": 8197
},
"inputChunkChecksums": {
"public/assets/game/arenas/nagrand-arena/collision.glb": "c5ec36344bc335aad5c63607a93d6cacb535d4055c2626fed7da250855f5fa74"
},
"inputChunks": [
"public/assets/game/arenas/nagrand-arena/collision.glb"
],
"output": "public/assets/game/arenas/nagrand-arena/navigation-raw.glb",
"schemaVersion": 1,
"settings": {
"agentHeight": 1.8,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"agentRadius": 0.36,
"cellHeight": 0.1,
"cellSize": 0.2
},
"source": {
"files": [
"../LadiksMPQEditor/common-2.mpq/World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01_000.wmo"
],
"kind": "direct-wmo-parser"
}
}
@@ -0,0 +1,94 @@
{
"schemaVersion": 1,
"id": "nagrand-arena",
"title": "Nagrand Arena",
"status": "generated-review-required",
"source": {
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01.wmo",
"groupCount": 1
},
"coordinateSystem": {
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
"rebasedOrigin": [
49.19449806213379,
-10.582855224609375,
30.535707473754883
]
},
"sourceBounds": {
"min": [
-28.017337799072266,
-10.582855224609375,
-47.70603561401367
],
"max": [
126.40633392333984,
71.53470611572266,
108.77745056152344
]
},
"bounds": {
"min": [
-77.21183776855469,
0,
-78.24174499511719
],
"max": [
77.21183776855469,
82.11756134033203,
78.24174499511719
]
},
"geometry": {
"groups": 1,
"vertices": 8197,
"triangles": 7727,
"collisionTriangles": 7727,
"triangleFlags": [
{
"group": "group-000",
"counts": {
"8": 783,
"32": 35,
"36": 831,
"38": 2,
"72": 2230,
"96": 502,
"100": 3291,
"102": 53
}
}
]
},
"navigation": {
"triangles": 1450,
"vertices": 4350,
"sourceComponents": 155,
"spawnDistances": [
0.0025342739142059146,
0.0025336963502397706
],
"rawTriangles": 3536,
"report": "recast-report.json"
},
"assets": [
{
"role": "visual",
"file": "visual.glb",
"bytes": 290580,
"sha256": "6730970440c31df26e4fe90a6eb3913e6402f0027a334d966fb2b57c2e7b3e7c"
},
{
"role": "collision",
"file": "collision.glb",
"bytes": 191916,
"sha256": "c5ec36344bc335aad5c63607a93d6cacb535d4055c2626fed7da250855f5fa74"
},
{
"role": "navigation",
"file": "navigation.glb",
"bytes": 70512,
"sha256": "cd9027c8d3cf175a8879efad1eb51584f3fbce1bc2eb9355959676471e06798a"
}
]
}
Binary file not shown.
@@ -0,0 +1,89 @@
{
"build": {
"attempts": [
{
"failures": [],
"tileCount": 1,
"tileSize": null,
"valid": true
}
],
"gridCells": 2185272,
"gridHeight": 1208,
"gridWidth": 1809,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
"partition": "watershed",
"populatedTileCount": 1,
"tileColumns": 1,
"tileCount": 1,
"tileRows": 1,
"tileSize": null
},
"dungeonId": "ring-of-valor",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
-180.21835327148438,
0.10000000149011612,
-117.37078094482422
],
[
180.181640625,
100.80000305175781,
120.02922058105469
]
],
"boundsTolerance": 0.4,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 4405,
"sourceVertices": 13215,
"triangles": 4405,
"vertices": 13215
},
"input": {
"bounds": [
[
-180.818359375,
0,
-120.77078247070312
],
[
180.818359375,
116.26216125488281,
120.77078247070312
]
],
"degenerateTriangleCount": 0,
"invalidIndexCount": 0,
"meshCount": 1,
"nonFiniteCoordinateCount": 0,
"triangleCount": 9713,
"vertexCount": 12167
},
"inputChunkChecksums": {
"public/assets/game/arenas/ring-of-valor/collision.glb": "25162e36ed6fea03d6de939cf4d742bc142fbebb2db1c9fbc6f2fcc9e15cffdf"
},
"inputChunks": [
"public/assets/game/arenas/ring-of-valor/collision.glb"
],
"output": "public/assets/game/arenas/ring-of-valor/navigation-raw.glb",
"schemaVersion": 1,
"settings": {
"agentHeight": 1.8,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"agentRadius": 0.36,
"cellHeight": 0.1,
"cellSize": 0.2
},
"source": {
"files": [
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena_000.wmo"
],
"kind": "direct-wmo-parser"
}
}
@@ -0,0 +1,94 @@
{
"schemaVersion": 1,
"id": "ring-of-valor",
"title": "Ring of Valor",
"status": "generated-review-required",
"source": {
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena.wmo",
"groupCount": 1
},
"coordinateSystem": {
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
"rebasedOrigin": [
-0.44493865966796875,
-32.01484298706055,
0
]
},
"sourceBounds": {
"min": [
-181.26329040527344,
-32.01484298706055,
-120.77078247070312
],
"max": [
180.3734130859375,
84.24732208251953,
120.77078247070312
]
},
"bounds": {
"min": [
-180.818359375,
0,
-120.77078247070312
],
"max": [
180.818359375,
116.26216125488281,
120.77078247070312
]
},
"geometry": {
"groups": 1,
"vertices": 12167,
"triangles": 9713,
"collisionTriangles": 9713,
"triangleFlags": [
{
"group": "group-000",
"counts": {
"8": 18,
"32": 1063,
"36": 590,
"38": 586,
"72": 126,
"96": 1307,
"100": 5028,
"102": 995
}
}
]
},
"navigation": {
"triangles": 702,
"vertices": 2106,
"sourceComponents": 61,
"spawnDistances": [
1.1157695927091633,
1.258558140579426
],
"rawTriangles": 4405,
"report": "recast-report.json"
},
"assets": [
{
"role": "visual",
"file": "visual.glb",
"bytes": 409692,
"sha256": "32d3dc93c8f5026d29f3e9bf2efb299145e8e191e358b201a62dc987e0fa3c46"
},
{
"role": "collision",
"file": "collision.glb",
"bytes": 263388,
"sha256": "25162e36ed6fea03d6de939cf4d742bc142fbebb2db1c9fbc6f2fcc9e15cffdf"
},
{
"role": "navigation",
"file": "navigation.glb",
"bytes": 34608,
"sha256": "db986ef21767aecf08edb435f11674a094996213685f83cd7d2172886949374d"
}
]
}
Binary file not shown.
@@ -0,0 +1,93 @@
{
"build": {
"attempts": [
{
"failures": [],
"tileCount": 1,
"tileSize": null,
"valid": true
}
],
"gridCells": 1226343,
"gridHeight": 1213,
"gridWidth": 1011,
"ignoredEmptyTilePackingFailures": 0,
"intermediateRetention": null,
"mode": "solo",
"partition": "watershed",
"populatedTileCount": 1,
"tileColumns": 1,
"tileCount": 1,
"tileRows": 1,
"tileSize": null
},
"dungeonId": "ruins-of-lordaeron",
"generator": "@recast-navigation/three@0.43.1",
"geometry": {
"bounds": [
[
-100.48125457763672,
3.5,
-119.45735168457031
],
[
96.71875,
77.70000457763672,
120.54264831542969
]
],
"boundsTolerance": 0.4,
"rejectedOutOfBoundsTriangles": 0,
"sourceTriangles": 7958,
"sourceVertices": 23874,
"triangles": 7958,
"vertices": 23874
},
"input": {
"bounds": [
[
-101.08125305175781,
0,
-121.25735473632812
],
[
101.08125305175781,
77.79415893554688,
121.25735473632812
]
],
"degenerateTriangleCount": 0,
"invalidIndexCount": 0,
"meshCount": 5,
"nonFiniteCoordinateCount": 0,
"triangleCount": 23642,
"vertexCount": 26192
},
"inputChunkChecksums": {
"public/assets/game/arenas/ruins-of-lordaeron/collision.glb": "a844092b66cbfcc8ae430879de3ce7a1e6441c2cfc69328dc2cb259e4cb6c645"
},
"inputChunks": [
"public/assets/game/arenas/ruins-of-lordaeron/collision.glb"
],
"output": "public/assets/game/arenas/ruins-of-lordaeron/navigation-raw.glb",
"schemaVersion": 1,
"settings": {
"agentHeight": 1.8,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"agentRadius": 0.36,
"cellHeight": 0.1,
"cellSize": 0.2
},
"source": {
"files": [
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_000.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_001.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_002.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_003.wmo",
"../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena_004.wmo"
],
"kind": "direct-wmo-parser"
}
}
@@ -0,0 +1,138 @@
{
"schemaVersion": 1,
"id": "ruins-of-lordaeron",
"title": "Ruins of Lordaeron",
"status": "generated-review-required",
"source": {
"root": "../LadiksMPQEditor/common-2.mpq/World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena.wmo",
"groupCount": 5
},
"coordinateSystem": {
"conversion": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
"rebasedOrigin": [
4.71783447265625,
-31.21632957458496,
15.956310272216797
]
},
"sourceBounds": {
"min": [
-96.36341857910156,
-31.21632957458496,
-105.3010482788086
],
"max": [
105.79908752441406,
46.57782745361328,
137.2136688232422
]
},
"bounds": {
"min": [
-101.08125305175781,
0,
-121.25735473632812
],
"max": [
101.08125305175781,
77.79415893554688,
121.25735473632812
]
},
"geometry": {
"groups": 5,
"vertices": 26192,
"triangles": 23642,
"collisionTriangles": 23642,
"triangleFlags": [
{
"group": "group-000",
"counts": {
"8": 1135,
"36": 3730,
"72": 730,
"100": 10270
}
},
{
"group": "group-001",
"counts": {
"8": 1,
"9": 2,
"32": 106,
"33": 22,
"36": 21,
"37": 14,
"72": 275,
"73": 47,
"96": 225,
"97": 236,
"100": 2550,
"101": 117
}
},
{
"group": "group-002",
"counts": {
"32": 21,
"96": 132
}
},
{
"group": "group-003",
"counts": {
"8": 13,
"9": 2,
"32": 185,
"33": 22,
"36": 53,
"37": 14,
"72": 252,
"73": 56,
"96": 276,
"97": 229,
"100": 2579,
"101": 174
}
},
{
"group": "group-004",
"counts": {
"36": 21,
"100": 132
}
}
]
},
"navigation": {
"triangles": 2085,
"vertices": 6255,
"sourceComponents": 157,
"spawnDistances": [
0.00292914523132256,
0.004205306248988376
],
"rawTriangles": 7958,
"report": "recast-report.json"
},
"assets": [
{
"role": "visual",
"file": "visual.glb",
"bytes": 916788,
"sha256": "b00bcd87bd72bbabcf3e7e7edab99e8f7ddbe820b849a40df90510096e9f2f4e"
},
{
"role": "collision",
"file": "collision.glb",
"bytes": 601048,
"sha256": "a844092b66cbfcc8ae430879de3ce7a1e6441c2cfc69328dc2cb259e4cb6c645"
},
{
"role": "navigation",
"file": "navigation.glb",
"bytes": 100996,
"sha256": "be7aa6c4822f0baecafe4eb148a5952d3ce8001c7617f351e7ab257d61e7cb59"
}
]
}
@@ -0,0 +1,39 @@
{
"schemaVersion": 1,
"id": "warnorken-arena",
"title": "Warnorken Arena",
"status": "validated",
"source": {
"kind": "validated-runewaker-package",
"navigationSource": "navigation-source.glb"
},
"navigation": {
"sourceComponents": 96,
"triangles": 365,
"vertices": 1095,
"spawnDistances": [
0.00402499431590021,
0.004012153291366225
]
},
"assets": [
{
"file": "visual.glb",
"bytes": 3752136,
"triangles": 55200,
"sha256": "c452a843c2e4123d4fe0d20da41558f5b5a1906548b3d091dc691ffc42ed19d9"
},
{
"file": "collision.glb",
"bytes": 3005760,
"triangles": 60751,
"sha256": "0b1ce7555da0d2900465e3539e1bf17ac244d23a0f5eb57a2106f29570757c5c"
},
{
"file": "navigation.glb",
"bytes": 18436,
"triangles": 365,
"sha256": "c6085ffc96b40b1f437b8bb7e111604c135a856b02fd9b6ccae20243183cf417"
}
]
}
Binary file not shown.
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.14",
"versionCode": 1014
"versionName": "0.1.16",
"versionCode": 1016
}
@@ -0,0 +1,322 @@
import { createHash } from "node:crypto";
import { access, mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs";
import { extractNavigationComponent } from "./extract-navigation-component.mjs";
import { MeshoptDecoder } from "meshoptimizer";
import { Vector3 } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import {
geometryBounds,
parseRootGroupCount,
parseWmoGroup,
rebaseWmoGroups,
wmoChunks,
writeWmoGeometry,
} from "./build-wmo-arenas.mjs";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const extractedRoot = path.resolve(projectRoot, "../LadiksMPQEditor");
const outputRoot = path.join(projectRoot, "public/assets/game/arenas");
const sourceLayers = ["patch-WC1", "patch-WB2", "patch-WB1", "patch-2", "patch", "lichking", "common-2.mpq"];
const DEFINITIONS = Object.freeze([
{
slug: "black-rook-hold-arena",
title: "Black Rook Hold Arena",
mapDirectory: "patch-WB1/world/maps/blackrookholdarena",
primaryPattern: /7vs_blackrookhold_arena\.wmo$/i,
alignmentOffset: [117.542488, -102.29949, -224.463489],
legacyCollision: "public/assets/game/manastorm/1784-blackrookholdarena/collision.glb",
gameplayBounds: { min: [-82, -10, -242], max: [312, 30, -110] },
legacyNavigation: "public/assets/game/manastorm/1784-blackrookholdarena/navigation.glb",
teamSpawns: [[-66.098564, 8.416428, -201.852448], [123.158585, 9.916428, -219.909592]],
},
{
slug: "ashamanes-fall",
title: "Ashamane's Fall",
mapDirectory: "patch-WB2/world/maps/valsharaharena",
primaryPattern: /custom_valsharah_arena\.wmo$/i,
alignmentOffset: [34.174934, -32.347685, 20.31486],
legacyCollision: "public/assets/game/manastorm/1785-valsharaharena/collision.glb",
gameplayBounds: { min: [14, -4, 2], max: [58, 16, 36] },
teamSpawns: [[18.223099, -0.480263, 32.034554], [55.023094, -0.480263, 7.123436]],
},
]);
function strings(buffer) {
return buffer.toString("utf8").split("\0").filter(Boolean);
}
export function parseAdtPlacements(buffer) {
const top = wmoChunks(buffer);
const mwmo = top.get("MWMO");
const mwid = top.get("MWID");
const modf = top.get("MODF");
if (!mwmo || !mwid || !modf) return [];
const names = new Map();
for (let offset = 0; offset < mwmo.length;) {
const end = mwmo.indexOf(0, offset);
if (end < 0) break;
if (end > offset) names.set(offset, mwmo.toString("utf8", offset, end));
offset = end + 1;
}
const offsets = [];
for (let offset = 0; offset + 4 <= mwid.length; offset += 4) offsets.push(mwid.readUInt32LE(offset));
const placements = [];
for (let offset = 0; offset + 64 <= modf.length; offset += 64) {
const nameIndex = modf.readUInt32LE(offset);
placements.push({
path: names.get(offsets[nameIndex]) ?? strings(mwmo)[nameIndex] ?? null,
uniqueId: modf.readUInt32LE(offset + 4),
position: [modf.readFloatLE(offset + 8), modf.readFloatLE(offset + 12), modf.readFloatLE(offset + 16)],
rotation: [modf.readFloatLE(offset + 20), modf.readFloatLE(offset + 24), modf.readFloatLE(offset + 28)],
scale: modf.readUInt16LE(offset + 62) / 1024 || 1,
});
}
return placements.filter((placement) => placement.path);
}
async function exists(file) {
try { await access(file); return true; } catch { return false; }
}
async function resolveWmo(relativePath) {
const normalized = relativePath.replace(/\\/g, "/");
for (const layer of sourceLayers) {
const file = path.join(extractedRoot, layer, ...normalized.split("/"));
if (await exists(file)) return file;
}
return null;
}
function rotate([x, y, z], [xDegrees, yDegrees, zDegrees]) {
const radians = [xDegrees, yDegrees, zDegrees].map((degrees) => degrees * Math.PI / 180);
let [nextX, nextY, nextZ] = [x, y, z];
let cosine = Math.cos(radians[0]); let sine = Math.sin(radians[0]);
[nextY, nextZ] = [nextY * cosine - nextZ * sine, nextY * sine + nextZ * cosine];
cosine = Math.cos(radians[1]); sine = Math.sin(radians[1]);
[nextX, nextZ] = [nextX * cosine + nextZ * sine, -nextX * sine + nextZ * cosine];
cosine = Math.cos(radians[2]); sine = Math.sin(radians[2]);
[nextX, nextY] = [nextX * cosine - nextY * sine, nextX * sine + nextY * cosine];
return [nextX, nextY, nextZ];
}
function placementBase(position) {
return [17066.6666666667 - position[2], 17066.6666666667 - position[0], position[1]];
}
function placeGroup(group, placement, primaryBase) {
const base = placementBase(placement.position);
const positions = new Float32Array(group.positions.length);
const normals = group.normals ? new Float32Array(group.normals.length) : null;
for (let offset = 0; offset < group.positions.length; offset += 3) {
// Undo the direct parser's Three-axis conversion before applying the MODF transform.
const local = [-group.positions[offset], group.positions[offset + 2], group.positions[offset + 1]];
const transformed = rotate(local.map((value) => value * placement.scale), placement.rotation);
positions[offset] = -(base[0] + transformed[0] - primaryBase[0]);
positions[offset + 1] = base[2] + transformed[2] - primaryBase[2];
positions[offset + 2] = base[1] + transformed[1] - primaryBase[1];
if (normals) {
const localNormal = [-group.normals[offset], group.normals[offset + 2], group.normals[offset + 1]];
const transformedNormal = rotate(localNormal, placement.rotation);
normals[offset] = -transformedNormal[0];
normals[offset + 1] = transformedNormal[2];
normals[offset + 2] = transformedNormal[1];
}
}
return { ...group, positions, normals };
}
async function loadPlacementGroups(placement, placementIndex, primaryBase) {
const rootFile = await resolveWmo(placement.path);
if (!rootFile) throw new Error(`Missing placed WMO: ${placement.path}`);
const root = await readFile(rootFile);
const groupCount = parseRootGroupCount(root);
const extension = path.extname(rootFile);
const base = rootFile.slice(0, -extension.length);
const groups = [];
for (let groupIndex = 0; groupIndex < groupCount; groupIndex += 1) {
const groupFile = `${base}_${String(groupIndex).padStart(3, "0")}${extension}`;
let parsed;
try {
parsed = parseWmoGroup(await readFile(groupFile), `placement-${placementIndex}-group-${groupIndex}`);
} catch (error) {
throw new Error(`${placement.path} group ${groupIndex}: ${error.message}`, { cause: error });
}
groups.push(placeGroup(parsed, placement, primaryBase));
}
return { rootFile, groups };
}
function splitCollisionGroups(groups, triangleLimit = 90000) {
const chunks = [];
let current = [];
let currentTriangles = 0;
for (const group of groups) {
let indexOffset = 0;
while (indexOffset < group.collisionIndices.length) {
const availableTriangles = triangleLimit - currentTriangles;
if (availableTriangles <= 0) {
chunks.push(current);
current = [];
currentTriangles = 0;
continue;
}
const takeIndices = Math.min(group.collisionIndices.length - indexOffset, availableTriangles * 3);
current.push({ ...group, collisionIndices: group.collisionIndices.slice(indexOffset, indexOffset + takeIndices) });
currentTriangles += takeIndices / 3;
indexOffset += takeIndices;
}
}
if (current.length) chunks.push(current);
return chunks;
}
async function hash(file) {
return createHash("sha256").update(await readFile(file)).digest("hex");
}
function triangleInsideGameplayBounds(points, bounds) {
const centroid = [0, 1, 2].map((axis) => (points[0][axis] + points[1][axis] + points[2][axis]) / 3);
return centroid[0] >= bounds.min[0] - 6 && centroid[0] <= bounds.max[0] + 6
&& centroid[1] >= bounds.min[1] - 6 && centroid[1] <= bounds.max[1] + 16
&& centroid[2] >= bounds.min[2] - 6 && centroid[2] <= bounds.max[2] + 6;
}
async function croppedNavigationGroup(legacyCollisionFile, groups, bounds) {
await MeshoptDecoder.ready;
const bytes = await readFile(legacyCollisionFile);
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
const scene = await new Promise((resolve, reject) => new GLTFLoader()
.setMeshoptDecoder(MeshoptDecoder)
.parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
scene.updateMatrixWorld(true);
const output = [];
const a = new Vector3();
const b = new Vector3();
const c = new Vector3();
scene.traverse((object) => {
if (!object.isMesh || !object.geometry?.getAttribute("position")) return;
const positions = object.geometry.getAttribute("position");
const indices = object.geometry.getIndex();
const count = indices?.count ?? positions.count;
const vertexIndex = (element) => indices ? indices.getX(element) : element;
for (let element = 0; element < count; element += 3) {
const points = [a, b, c].map((point, corner) => point.fromBufferAttribute(positions, vertexIndex(element + corner)).applyMatrix4(object.matrixWorld).toArray());
if (triangleInsideGameplayBounds(points, bounds)) output.push(...points.flat());
}
});
for (const group of groups) {
for (let offset = 0; offset < group.collisionIndices.length; offset += 3) {
const points = [0, 1, 2].map((corner) => {
const index = group.collisionIndices[offset + corner] * 3;
return [group.positions[index], group.positions[index + 1], group.positions[index + 2]];
});
if (triangleInsideGameplayBounds(points, bounds)) output.push(...points.flat());
}
}
const positions = new Float32Array(output);
const indices = Uint32Array.from({ length: positions.length / 3 }, (_, index) => index);
return { name: "rated-gameplay-collision", positions, normals: null, indices, collisionIndices: indices, flagCounts: {} };
}
async function build(definition) {
const mapRoot = path.join(extractedRoot, ...definition.mapDirectory.split("/"));
const adtFiles = (await readdir(mapRoot)).filter((name) => name.toLowerCase().endsWith(".adt")).sort();
const placementsById = new Map();
for (const adt of adtFiles) {
for (const placement of parseAdtPlacements(await readFile(path.join(mapRoot, adt)))) placementsById.set(placement.uniqueId, placement);
}
const placements = [...placementsById.values()];
const primary = placements.find((placement) => definition.primaryPattern.test(placement.path));
if (!primary) throw new Error(`${definition.slug}: primary arena WMO placement was not found.`);
const primaryBase = placementBase(primary.position);
const groups = [];
const sources = [];
for (const [index, placement] of placements.entries()) {
const loaded = await loadPlacementGroups(placement, index, primaryBase);
groups.push(...loaded.groups);
sources.push(path.relative(projectRoot, loaded.rootFile).replace(/\\/g, "/"));
}
const transform = rebaseWmoGroups(groups);
for (const group of groups) {
for (let offset = 0; offset < group.positions.length; offset += 3) {
group.positions[offset] += definition.alignmentOffset[0];
group.positions[offset + 1] += definition.alignmentOffset[1];
group.positions[offset + 2] += definition.alignmentOffset[2];
}
}
const alignedBounds = geometryBounds(groups);
const outputDirectory = path.join(outputRoot, definition.slug);
await mkdir(outputDirectory, { recursive: true });
const visualFile = path.join(outputDirectory, "visual.glb");
await writeWmoGeometry(visualFile, groups, true);
const collisionChunks = splitCollisionGroups(groups);
const collisionFiles = [];
for (const [index, chunk] of collisionChunks.entries()) {
const file = path.join(outputDirectory, index === 0 ? "collision.glb" : `collision-${String(index).padStart(3, "0")}.glb`);
await writeWmoGeometry(file, chunk, false);
collisionFiles.push(file);
}
const navigationFile = path.join(outputDirectory, "navigation.glb");
const rawNavigationFile = path.join(outputDirectory, "navigation-raw.glb");
const legacyCollisionFile = path.join(projectRoot, ...definition.legacyCollision.split("/"));
const navigationCollisionFile = path.join(outputDirectory, "navigation-collision.glb");
const navigationGroup = await croppedNavigationGroup(legacyCollisionFile, groups, definition.gameplayBounds);
await writeWmoGeometry(navigationCollisionFile, [navigationGroup], false);
const navigation = await bakeNavigationFromCollisionAssets({
slug: definition.slug,
collisionAssets: [{ id: "rated-gameplay-collision", role: "collision", path: path.relative(projectRoot, navigationCollisionFile).replace(/\\/g, "/") }],
settings: { cellSize: 0.2, cellHeight: 0.1, agentHeight: 1.8, agentRadius: 0.36, agentMaxClimb: 0.5, agentMaxSlope: 50 },
output: rawNavigationFile,
reportFile: path.join(outputDirectory, "recast-report.json"),
source: { kind: "adt-placed-wmo-repair", adtFiles, placedWmos: sources, legacyCollision: definition.legacyCollision, gameplayBounds: definition.gameplayBounds },
});
const selectionSource = definition.legacyNavigation
? path.join(projectRoot, ...definition.legacyNavigation.split("/"))
: rawNavigationFile;
const connectedNavigation = await extractNavigationComponent({
source: selectionSource,
output: navigationFile,
teamSpawns: definition.teamSpawns,
});
await Promise.all([unlink(rawNavigationFile), unlink(navigationCollisionFile)]);
const files = [visualFile, ...collisionFiles, navigationFile];
const report = {
schemaVersion: 1,
id: definition.slug,
title: definition.title,
status: "generated-review-required",
source: { mapDirectory: definition.mapDirectory, adtFiles, placementCount: placements.length, placedWmos: sources },
coordinateSystem: {
conversion: "three(x,y,z)=(-wow.x,wow.z,wow.y)",
rebasedOrigin: transform.origin,
alignmentOffset: definition.alignmentOffset,
alignment: "primary WMO matched to the existing HealerMan stage coordinate frame",
},
bounds: alignedBounds,
geometry: {
groups: groups.length,
triangles: groups.reduce((sum, group) => sum + group.indices.length / 3, 0),
collisionTriangles: groups.reduce((sum, group) => sum + group.collisionIndices.length / 3, 0),
collisionChunks: collisionFiles.length,
},
navigation: {
triangles: connectedNavigation.triangles,
vertices: connectedNavigation.vertices,
sourceComponents: connectedNavigation.sourceComponents,
spawnDistances: connectedNavigation.spawnDistances,
rawTriangles: navigation.geometry.triangles,
report: "recast-report.json",
},
assets: await Promise.all(files.map(async (file) => ({ file: path.basename(file), bytes: (await readFile(file)).length, sha256: await hash(file) }))),
};
await writeFile(path.join(outputDirectory, "stage-pack.json"), `${JSON.stringify(report, null, 2)}\n`);
console.log(`${definition.slug}: ${placements.length} placed WMOs, ${report.geometry.triangles} triangles, ${report.navigation.triangles} nav triangles`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
const requested = new Set(process.argv.slice(2));
for (const definition of DEFINITIONS.filter((entry) => !requested.size || requested.has(entry.slug))) await build(definition);
}
+249
View File
@@ -0,0 +1,249 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { Document, NodeIO } from "@gltf-transform/core";
import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs";
import { extractNavigationComponent } from "./extract-navigation-component.mjs";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const sourceRoot = path.resolve(projectRoot, "../LadiksMPQEditor/common-2.mpq");
const outputRoot = path.join(projectRoot, "public/assets/game/arenas");
const ARENAS = Object.freeze([
{ slug: "nagrand-arena", title: "Nagrand Arena", root: "World/wmo/Dungeon/OL_OgreHuts/PVP_Ogre_Arena01.wmo", teamSpawns: [[-43.41, 13.4, -42.04], [42.03, 14.5, -44.44]] },
{ slug: "blades-edge-arena", title: "Blade's Edge Arena", root: "World/wmo/PvP/Buildings/AncientOrcArena/ancorc_PVPstadium.wmo", teamSpawns: [[29.2, 50.7, -51.49], [31.13, 54.4, 49.51]] },
{ slug: "ruins-of-lordaeron", title: "Ruins of Lordaeron", root: "World/wmo/PvP/Buildings/Lordaeron/PVP_Lordaeron_Arena.wmo", teamSpawns: [[-8.68, 30.1, -73.96], [-5.08, 27.6, 57.28]] },
{ slug: "dalaran-sewers", title: "Dalaran Sewers", root: "World/wmo/PvP/Buildings/Dalaran/dalaran_sewer_arena.wmo", teamSpawns: [[-12.41, 20.1, -63.05], [21.79, 20.2, 25.15]] },
{ slug: "ring-of-valor", title: "Ring of Valor", root: "World/wmo/PvP/Buildings/Orgrimmar/OrgrimmarArena.wmo", teamSpawns: [[0, 27, 25], [0, 27, -25]] },
]);
function tag(buffer, offset) {
return buffer.toString("ascii", offset, offset + 4).split("").reverse().join("");
}
export function wmoChunks(buffer, start = 0, end = buffer.length) {
const result = new Map();
let offset = start;
while (offset + 8 <= end) {
const id = tag(buffer, offset);
const size = buffer.readUInt32LE(offset + 4);
const payloadStart = offset + 8;
let payloadEnd = payloadStart + size;
// Some Legion-era custom group files report the original uncompressed MOGP
// container length even though trailing optional chunks were stripped.
// MOGP is the terminal top-level container, so the physical EOF is safe.
if (id === "MOGP" && payloadEnd > end) payloadEnd = end;
if (payloadEnd > end) throw new Error(`Invalid ${id} chunk at ${offset}: ${size} bytes exceeds the WMO boundary.`);
result.set(id, buffer.subarray(payloadStart, payloadEnd));
offset = payloadEnd;
}
return result;
}
export function parseRootGroupCount(buffer) {
const root = wmoChunks(buffer);
const header = root.get("MOHD");
if (!header || header.length < 8) throw new Error("WMO root is missing MOHD.");
return header.readUInt32LE(4);
}
export function parseWmoGroup(buffer, name) {
const top = wmoChunks(buffer);
const group = top.get("MOGP");
if (!group || group.length <= 68) throw new Error(`${name}: missing MOGP geometry.`);
const nested = wmoChunks(group, 68, group.length);
const vertexBytes = nested.get("MOVT");
const normalBytes = nested.get("MONR");
const indexBytes = nested.get("MOVI");
const materialBytes = nested.get("MOPY");
if (!vertexBytes || !indexBytes) throw new Error(`${name}: missing MOVT or MOVI.`);
const positions = new Float32Array(vertexBytes.length / 4);
for (let offset = 0; offset < vertexBytes.length; offset += 4) positions[offset / 4] = vertexBytes.readFloatLE(offset);
const sourceNormals = normalBytes ? new Float32Array(normalBytes.length / 4) : null;
if (normalBytes) for (let offset = 0; offset < normalBytes.length; offset += 4) sourceNormals[offset / 4] = normalBytes.readFloatLE(offset);
const indices = new Uint32Array(indexBytes.length / 2);
for (let offset = 0; offset < indexBytes.length; offset += 2) indices[offset / 2] = indexBytes.readUInt16LE(offset);
const transformed = new Float32Array(positions.length);
const normals = sourceNormals?.length === positions.length ? new Float32Array(positions.length) : null;
for (let index = 0; index < positions.length; index += 3) {
transformed[index] = -positions[index];
transformed[index + 1] = positions[index + 2];
transformed[index + 2] = positions[index + 1];
if (normals) {
normals[index] = -sourceNormals[index];
normals[index + 1] = sourceNormals[index + 2];
normals[index + 2] = sourceNormals[index + 1];
}
}
const flagCounts = {};
const collisionIndexValues = [];
if (materialBytes) {
for (let offset = 0; offset < materialBytes.length; offset += 2) {
const flag = materialBytes[offset];
flagCounts[flag] = (flagCounts[flag] ?? 0) + 1;
if (flag & 0x04) {
const triangleOffset = (offset / 2) * 3;
collisionIndexValues.push(indices[triangleOffset], indices[triangleOffset + 1], indices[triangleOffset + 2]);
}
}
}
return {
name, positions: transformed, normals, indices, flagCounts,
// Retain every structural WMO triangle for authoritative collision. MOPY
// bit meanings vary by client generation and several arena floors use flag
// combinations that would otherwise be omitted; Recast filters slopes.
collisionIndices: indices,
flaggedCollisionIndices: materialBytes ? new Uint32Array(collisionIndexValues) : indices,
};
}
export function geometryBounds(groups) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (const group of groups) {
for (let offset = 0; offset < group.positions.length; offset += 3) {
for (let axis = 0; axis < 3; axis += 1) {
min[axis] = Math.min(min[axis], group.positions[offset + axis]);
max[axis] = Math.max(max[axis], group.positions[offset + axis]);
}
}
}
return { min, max };
}
export function rebaseWmoGroups(groups) {
const sourceBounds = geometryBounds(groups);
const center = [
(sourceBounds.min[0] + sourceBounds.max[0]) / 2,
sourceBounds.min[1],
(sourceBounds.min[2] + sourceBounds.max[2]) / 2,
];
for (const group of groups) {
for (let offset = 0; offset < group.positions.length; offset += 3) {
group.positions[offset] -= center[0];
group.positions[offset + 1] -= center[1];
group.positions[offset + 2] -= center[2];
}
}
return { sourceBounds, origin: center, bounds: geometryBounds(groups) };
}
export async function writeWmoGeometry(file, groups, visual) {
const document = new Document();
const buffer = document.createBuffer("arena-buffer");
const scene = document.createScene("arena");
const colors = [
[0.37, 0.32, 0.22, 1], [0.29, 0.34, 0.3, 1], [0.42, 0.4, 0.34, 1],
[0.31, 0.27, 0.24, 1], [0.34, 0.37, 0.42, 1],
];
for (const [index, group] of groups.entries()) {
const groupIndices = visual ? group.indices : group.collisionIndices;
if (!groupIndices.length) continue;
const position = document.createAccessor(`${group.name}-position`, buffer).setType("VEC3").setArray(group.positions);
const indices = document.createAccessor(`${group.name}-indices`, buffer).setType("SCALAR").setArray(groupIndices);
const primitive = document.createPrimitive().setAttribute("POSITION", position).setIndices(indices);
if (visual && group.normals) {
primitive.setAttribute("NORMAL", document.createAccessor(`${group.name}-normal`, buffer).setType("VEC3").setArray(group.normals));
}
if (visual) {
primitive.setMaterial(document.createMaterial(`${group.name}-stone`)
.setBaseColorFactor(colors[index % colors.length])
.setRoughnessFactor(0.84)
.setMetallicFactor(0.02)
.setDoubleSided(true));
}
const mesh = document.createMesh(group.name).addPrimitive(primitive);
scene.addChild(document.createNode(group.name).setMesh(mesh));
}
await new NodeIO().write(file, document);
}
async function sha256(file) {
const buffer = await readFile(file);
return createHash("sha256").update(buffer).digest("hex");
}
async function buildArena(definition) {
const rootFile = path.join(sourceRoot, ...definition.root.split("/"));
const root = await readFile(rootFile);
const groupCount = parseRootGroupCount(root);
const extension = path.extname(rootFile);
const base = rootFile.slice(0, -extension.length);
const groups = [];
const sourceFiles = [rootFile];
for (let index = 0; index < groupCount; index += 1) {
const groupFile = `${base}_${String(index).padStart(3, "0")}${extension}`;
const data = await readFile(groupFile);
sourceFiles.push(groupFile);
groups.push(parseWmoGroup(data, `group-${String(index).padStart(3, "0")}`));
}
const transform = rebaseWmoGroups(groups);
const outputDirectory = path.join(outputRoot, definition.slug);
await mkdir(outputDirectory, { recursive: true });
const visualFile = path.join(outputDirectory, "visual.glb");
const collisionFile = path.join(outputDirectory, "collision.glb");
const navigationFile = path.join(outputDirectory, "navigation.glb");
const rawNavigationFile = path.join(outputDirectory, "navigation-raw.glb");
const recastReport = path.join(outputDirectory, "recast-report.json");
await writeWmoGeometry(visualFile, groups, true);
await writeWmoGeometry(collisionFile, groups, false);
const triangles = groups.reduce((sum, group) => sum + group.indices.length / 3, 0);
const collisionTriangles = groups.reduce((sum, group) => sum + group.collisionIndices.length / 3, 0);
if (collisionTriangles > 100000) throw new Error(`${definition.slug}: ${collisionTriangles} collision triangles exceed the rated limit.`);
const relativeCollision = path.relative(projectRoot, collisionFile).replace(/\\/g, "/");
const navigation = await bakeNavigationFromCollisionAssets({
slug: definition.slug,
collisionAssets: [{ id: "collision", role: "collision", path: relativeCollision }],
settings: { cellSize: 0.2, cellHeight: 0.1, agentHeight: 1.8, agentRadius: 0.36, agentMaxClimb: 0.5, agentMaxSlope: 50 },
output: rawNavigationFile,
reportFile: recastReport,
source: { kind: "direct-wmo-parser", files: sourceFiles.map((file) => path.relative(projectRoot, file).replace(/\\/g, "/")) },
});
const connectedNavigation = await extractNavigationComponent({
source: rawNavigationFile,
output: navigationFile,
teamSpawns: definition.teamSpawns,
});
await unlink(rawNavigationFile);
const assets = await Promise.all([
["visual", visualFile], ["collision", collisionFile], ["navigation", navigationFile],
].map(async ([role, file]) => ({ role, file: path.basename(file), bytes: (await readFile(file)).length, sha256: await sha256(file) })));
const report = {
schemaVersion: 1,
id: definition.slug,
title: definition.title,
status: "generated-review-required",
source: { root: path.relative(projectRoot, rootFile).replace(/\\/g, "/"), groupCount },
coordinateSystem: { conversion: "three(x,y,z)=(-wow.x,wow.z,wow.y)", rebasedOrigin: transform.origin },
sourceBounds: transform.sourceBounds,
bounds: transform.bounds,
geometry: {
groups: groups.length,
vertices: groups.reduce((sum, group) => sum + group.positions.length / 3, 0),
triangles,
collisionTriangles,
triangleFlags: groups.map((group) => ({ group: group.name, counts: group.flagCounts })),
},
navigation: {
triangles: connectedNavigation.triangles,
vertices: connectedNavigation.vertices,
sourceComponents: connectedNavigation.sourceComponents,
spawnDistances: connectedNavigation.spawnDistances,
rawTriangles: navigation.geometry.triangles,
report: "recast-report.json",
},
assets,
};
await writeFile(path.join(outputDirectory, "stage-pack.json"), `${JSON.stringify(report, null, 2)}\n`);
return report;
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
const requested = new Set(process.argv.slice(2));
const selected = requested.size ? ARENAS.filter((arena) => requested.has(arena.slug)) : ARENAS;
if (!selected.length) throw new Error(`No matching arena. Choose: ${ARENAS.map((arena) => arena.slug).join(", ")}`);
for (const arena of selected) {
const report = await buildArena(arena);
console.log(`${arena.slug}: ${report.geometry.triangles} triangles, ${report.navigation.triangles} navigation triangles`);
}
}
@@ -0,0 +1,101 @@
import { readFile } from "node:fs/promises";
import { MeshoptDecoder } from "meshoptimizer";
import { Vector3 } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { writeWmoGeometry } from "./build-wmo-arenas.mjs";
globalThis.self = globalThis;
globalThis.ProgressEvent ??= class ProgressEvent {
constructor(type, values = {}) { this.type = type; Object.assign(this, values); }
};
class UnionFind {
constructor(size) { this.parent = Array.from({ length: size }, (_, index) => index); }
find(value) {
if (this.parent[value] !== value) this.parent[value] = this.find(this.parent[value]);
return this.parent[value];
}
union(left, right) {
const a = this.find(left);
const b = this.find(right);
if (a !== b) this.parent[b] = a;
}
}
function key(point) {
return point.map((value) => Math.round(value * 1000)).join(":");
}
async function loadTriangles(file) {
await MeshoptDecoder.ready;
const bytes = await readFile(file);
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
const scene = await new Promise((resolve, reject) => new GLTFLoader()
.setMeshoptDecoder(MeshoptDecoder)
.parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
scene.updateMatrixWorld(true);
const triangles = [];
const points = [new Vector3(), new Vector3(), new Vector3()];
scene.traverse((object) => {
if (!object.isMesh || !object.geometry?.getAttribute("position")) return;
const positions = object.geometry.getAttribute("position");
const indices = object.geometry.getIndex();
const count = indices?.count ?? positions.count;
const vertexIndex = (element) => indices ? indices.getX(element) : element;
for (let element = 0; element < count; element += 3) {
triangles.push(points.map((point, corner) => [
...point.fromBufferAttribute(positions, vertexIndex(element + corner)).applyMatrix4(object.matrixWorld).toArray(),
]));
}
});
return triangles;
}
function distanceSquared(point, target) {
return (point[0] - target[0]) ** 2 + (point[1] - target[1]) ** 2 + (point[2] - target[2]) ** 2;
}
export async function extractNavigationComponent({ source, output, teamSpawns }) {
const triangles = await loadTriangles(source);
const union = new UnionFind(triangles.length);
const ownerByVertex = new Map();
for (const [triangleIndex, triangle] of triangles.entries()) {
for (const point of triangle) {
const positionKey = key(point);
const owner = ownerByVertex.get(positionKey);
if (owner == null) ownerByVertex.set(positionKey, triangleIndex);
else union.union(owner, triangleIndex);
}
}
const components = new Map();
for (let index = 0; index < triangles.length; index += 1) {
const root = union.find(index);
const component = components.get(root) ?? [];
component.push(triangles[index]);
components.set(root, component);
}
const scored = [...components.values()].map((component) => {
const vertices = component.flat();
const spawnDistances = teamSpawns.map((spawn) => Math.sqrt(Math.min(...vertices.map((point) => distanceSquared(point, spawn)))));
return { component, spawnDistances, score: Math.max(...spawnDistances) };
}).sort((left, right) => left.score - right.score || right.component.length - left.component.length);
const selected = scored[0];
if (!selected?.component.length) throw new Error(`${source}: no navigation component could be selected.`);
const values = selected.component.flat(2);
const positions = new Float32Array(values);
const indices = Uint32Array.from({ length: positions.length / 3 }, (_, index) => index);
await writeWmoGeometry(output, [{
name: "rated-connected-navigation",
positions,
normals: null,
indices,
collisionIndices: indices,
flagCounts: {},
}], false);
return {
sourceComponents: components.size,
triangles: selected.component.length,
vertices: positions.length / 3,
spawnDistances: selected.spawnDistances,
};
}
@@ -0,0 +1,84 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { NodeIO } from "@gltf-transform/core";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const catalog = JSON.parse(await readFile(path.join(projectRoot, "shared/arena-stages.json"), "utf8"));
const io = new NodeIO();
const requested = new Set(process.argv.slice(2).filter((value) => !value.startsWith("--")));
const detail = process.argv.includes("--all");
class UnionFind {
constructor(size) { this.parent = Array.from({ length: size }, (_, index) => index); }
find(value) { if (this.parent[value] !== value) this.parent[value] = this.find(this.parent[value]); return this.parent[value]; }
union(left, right) { const a = this.find(left); const b = this.find(right); if (a !== b) this.parent[b] = a; }
}
function round(values) { return values.map((value) => Math.round(value * 100) / 100); }
for (const stage of catalog.stages) {
if (requested.size && !requested.has(stage.id)) continue;
const base = path.join(projectRoot, "public", ...stage.assetBaseUrl.split("/").filter(Boolean));
let document;
try { document = await io.read(path.join(base, "navigation.glb")); } catch { continue; }
const vertices = [];
const triangles = [];
const byPosition = new Map();
for (const mesh of document.getRoot().listMeshes()) {
for (const primitive of mesh.listPrimitives()) {
const positions = primitive.getAttribute("POSITION")?.getArray();
if (!positions) continue;
const indices = primitive.getIndices()?.getArray() ?? Uint32Array.from({ length: positions.length / 3 }, (_, index) => index);
for (let offset = 0; offset < indices.length; offset += 3) {
const triangle = [];
for (let corner = 0; corner < 3; corner += 1) {
const index = indices[offset + corner];
const vertex = [positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]];
const key = vertex.map((value) => Math.round(value * 1000)).join(":");
if (!byPosition.has(key)) {
byPosition.set(key, vertices.length);
vertices.push(vertex);
}
triangle.push(byPosition.get(key));
}
triangles.push(triangle);
}
}
}
const union = new UnionFind(vertices.length);
for (const triangle of triangles) {
union.union(triangle[0], triangle[1]);
union.union(triangle[0], triangle[2]);
}
const grouped = new Map();
for (let index = 0; index < vertices.length; index += 1) {
const root = union.find(index);
const component = grouped.get(root) ?? [];
component.push(vertices[index]);
grouped.set(root, component);
}
const components = [...grouped.values()];
components.sort((left, right) => right.length - left.length);
const main = components[0] ?? [];
const min = [0, 1, 2].map((axis) => Math.min(...main.map((vertex) => vertex[axis])));
const max = [0, 1, 2].map((axis) => Math.max(...main.map((vertex) => vertex[axis])));
const center = min.map((value, axis) => (value + max[axis]) / 2);
const axis = max[0] - min[0] >= max[2] - min[2] ? 0 : 2;
const sorted = [...main].sort((left, right) => left[axis] - right[axis]);
const inset = Math.max(1, Math.floor(sorted.length * 0.08));
const teamSpawns = [sorted[inset] ?? sorted[0], sorted[sorted.length - inset - 1] ?? sorted.at(-1)];
console.log(JSON.stringify({
id: stage.id,
componentCount: components.length,
mainVertices: main.length,
nextVertices: components[1]?.length ?? 0,
mainBounds: { min: round(min), max: round(max) },
center: round(center),
suggestedTeamSpawns: teamSpawns.map(round),
topComponents: components.slice(0, detail ? components.length : 8).map((component) => ({
vertices: component.length,
min: round([0, 1, 2].map((componentAxis) => Math.min(...component.map((vertex) => vertex[componentAxis])))),
max: round([0, 1, 2].map((componentAxis) => Math.max(...component.map((vertex) => vertex[componentAxis])))),
})),
}));
}
@@ -0,0 +1,33 @@
import { createHash } from "node:crypto";
import { readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { extractNavigationComponent } from "./extract-navigation-component.mjs";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const directory = path.join(projectRoot, "public/assets/game/arenas/warnorken-arena");
const source = path.join(directory, "navigation-source.glb");
const output = path.join(directory, "navigation.glb");
const teamSpawns = [[-42.7, -21.75, 14.12], [41.3, -21.65, -6.58]];
const connected = await extractNavigationComponent({ source, output, teamSpawns });
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const assets = [];
for (const name of ["visual", "collision", "navigation"]) {
const file = path.join(directory, `${name}.glb`);
const bytes = await readFile(file);
const document = await io.read(file);
const triangles = document.getRoot().listMeshes().reduce((sum, mesh) => sum + mesh.listPrimitives().reduce((primitiveSum, primitive) => primitiveSum + (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION")?.getCount() ?? 0) / 3, 0), 0);
assets.push({ file: `${name}.glb`, bytes: (await stat(file)).size, triangles: Math.round(triangles), sha256: createHash("sha256").update(bytes).digest("hex") });
}
await writeFile(path.join(directory, "stage-pack.json"), `${JSON.stringify({
schemaVersion: 1,
id: "warnorken-arena",
title: "Warnorken Arena",
status: "validated",
source: { kind: "validated-runewaker-package", navigationSource: "navigation-source.glb" },
navigation: connected,
assets,
}, null, 2)}\n`);
console.log(`warnorken-arena: ${connected.triangles} connected navigation triangles`);
+118
View File
@@ -0,0 +1,118 @@
import { access, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
const projectRoot = path.resolve(import.meta.dirname, "../..");
const catalog = JSON.parse(await readFile(path.join(projectRoot, "shared/arena-stages.json"), "utf8"));
const failures = [];
const reports = [];
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.decoder": MeshoptDecoder });
class UnionFind {
constructor(size) { this.parent = Array.from({ length: size }, (_, index) => index); }
find(value) {
if (this.parent[value] !== value) this.parent[value] = this.find(this.parent[value]);
return this.parent[value];
}
union(left, right) {
const a = this.find(left);
const b = this.find(right);
if (a !== b) this.parent[b] = a;
}
}
function inspectConnectedNavigation(document, spawns) {
const vertices = [];
const triangles = [];
const byPosition = new Map();
for (const mesh of document.getRoot().listMeshes()) {
for (const primitive of mesh.listPrimitives()) {
const positions = primitive.getAttribute("POSITION")?.getArray();
if (!positions) continue;
const indices = primitive.getIndices()?.getArray() ?? Uint32Array.from({ length: positions.length / 3 }, (_, index) => index);
for (let offset = 0; offset < indices.length; offset += 3) {
const triangle = [];
for (let corner = 0; corner < 3; corner += 1) {
const index = indices[offset + corner];
const point = [positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]];
const key = point.map((value) => Math.round(value * 1000)).join(":");
if (!byPosition.has(key)) {
byPosition.set(key, vertices.length);
vertices.push(point);
}
triangle.push(byPosition.get(key));
}
triangles.push(triangle);
}
}
}
const union = new UnionFind(vertices.length);
for (const triangle of triangles) {
union.union(triangle[0], triangle[1]);
union.union(triangle[0], triangle[2]);
}
const components = new Set(vertices.map((_, index) => union.find(index)));
const spawnDistances = spawns.map((spawn) => Math.sqrt(Math.min(...vertices.map((point) => (
(point[0] - spawn[0]) ** 2 + (point[1] - spawn[1]) ** 2 + (point[2] - spawn[2]) ** 2
)))));
return { components: components.size, vertices: vertices.length, triangles: triangles.length, spawnDistances };
}
function validateVec3(value, label, stage) {
if (!Array.isArray(value) || value.length !== 3 || value.some((entry) => !Number.isFinite(entry))) failures.push(`${stage.id}: invalid ${label}.`);
}
for (const stage of catalog.stages) {
validateVec3(stage.bounds?.min, "minimum bounds", stage);
validateVec3(stage.bounds?.max, "maximum bounds", stage);
for (const [index, spawn] of (stage.teamSpawns ?? []).entries()) validateVec3(spawn, `team spawn ${index}`, stage);
if (stage.teamSpawns?.length !== 2) failures.push(`${stage.id}: two team spawns are required.`);
if (stage.shadowSight?.length !== 2) failures.push(`${stage.id}: two Shadow Sight locations are required.`);
for (const [index, spawn] of (stage.teamSpawns ?? []).entries()) {
if (spawn.some((value, axis) => value < stage.bounds.min[axis] || value > stage.bounds.max[axis])) failures.push(`${stage.id}: team spawn ${index} is outside the sealed gameplay bounds.`);
}
for (const object of stage.lineOfSight ?? []) {
validateVec3(object.position, `${object.id} LOS position`, stage);
validateVec3(object.size, `${object.id} LOS size`, stage);
if (object.position[0] < stage.bounds.min[0] || object.position[0] > stage.bounds.max[0]
|| object.position[2] < stage.bounds.min[2] || object.position[2] > stage.bounds.max[2]) failures.push(`${stage.id}: ${object.id} LOS geometry is outside the gameplay bounds.`);
}
const dynamicIds = new Set((stage.dynamicObjects ?? []).map((object) => object.id));
for (const object of stage.lineOfSight ?? []) {
if (object.dynamicObjectId && !dynamicIds.has(object.dynamicObjectId)) failures.push(`${stage.id}: ${object.id} references a missing dynamic object.`);
}
const base = path.join(projectRoot, "public", ...stage.assetBaseUrl.split("/").filter(Boolean));
const assets = {};
for (const name of ["visual", "collision", "navigation"]) {
const file = path.join(base, `${name}.glb`);
try {
await access(file);
const info = await stat(file);
const document = await io.read(file);
const triangles = document.getRoot().listMeshes().reduce((sum, mesh) => sum + mesh.listPrimitives().reduce((primitiveSum, primitive) => primitiveSum + (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION")?.getCount() ?? 0) / 3, 0), 0);
assets[name] = { bytes: info.size, triangles: Math.round(triangles) };
if (name === "navigation") {
const navigation = inspectConnectedNavigation(document, stage.teamSpawns);
assets[name].components = navigation.components;
assets[name].spawnDistances = navigation.spawnDistances.map((distance) => Math.round(distance * 1000) / 1000);
if (navigation.components !== 1) failures.push(`${stage.id}: rated navigation must contain one connected gameplay component.`);
for (const [index, distance] of navigation.spawnDistances.entries()) {
if (distance > 3) failures.push(`${stage.id}: team spawn ${index} is ${distance.toFixed(2)}m from the navigation surface.`);
}
}
if (!triangles) failures.push(`${stage.id}: ${name}.glb has no triangles.`);
} catch {
if (stage.validation.status === "validated") failures.push(`${stage.id}: required ${name}.glb is missing or unreadable.`);
}
}
if (assets.visual?.bytes > 25 * 1024 * 1024) failures.push(`${stage.id}: visual asset exceeds 25 MiB.`);
if (stage.validation.status === "validated" && ["traversal", "collision", "spawnSafety", "lineOfSight", "performance"].some((key) => stage.validation[key] !== true)) {
failures.push(`${stage.id}: validated stages must carry every validation flag.`);
}
reports.push({ id: stage.id, validation: stage.validation, assets });
}
console.log(JSON.stringify({ schemaVersion: 1, arenaCount: reports.length, failures, stages: reports }, null, 2));
if (failures.length) process.exitCode = 1;
+3 -1
View File
@@ -13,6 +13,7 @@ import {
import { threeToSoloNavMesh, threeToTiledNavMesh, NavMeshHelper } from "@recast-navigation/three";
import { Vector3 } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { MeshoptDecoder } from "meshoptimizer";
import {
exists,
fixturesRoot,
@@ -39,9 +40,10 @@ globalThis.ProgressEvent ??= class ProgressEvent {
globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} });
async function loadScene(file) {
await MeshoptDecoder.ready;
const bytes = await readFile(file);
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
return new Promise((resolve, reject) => new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
return new Promise((resolve, reject) => new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
}
function meshesIn(scene) {
+46
View File
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
test("arena loadouts are character-owned, tank-free, and matches settle exactly once", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-arena-db-test-"));
const database = openGameDatabase({ dataDir: directory });
try {
const left = await database.register("ArenaLeft", "arena-left-pass");
const right = await database.register("ArenaRight", "arena-right-pass");
database.putCloudSave(left.account.id, { version: 1, characters: [{ id: "left-priest", name: "Left", classId: "priest", categoryId: "wow" }] });
database.putCloudSave(right.account.id, { version: 1, characters: [{ id: "right-mage", name: "Right", classId: "mage", categoryId: "wow" }] });
const loadout = database.putArenaLoadout(left.account.id, "left-priest", 1, {
name: "Disc Control", classId: "priest", specializationId: "priest:discipline",
role: "healer", ruleset: "wow", talents: { "priest:discipline:1": 5 }, actions: { "1": "heal" }, selected: true,
});
assert.equal(loadout.level, 80);
assert.equal(loadout.version, 1);
assert.equal(database.getArenaState(left.account.id, "left-priest").loadouts.length, 1);
assert.throws(() => database.putArenaLoadout(right.account.id, "right-mage", 1, {
name: "Protection", classId: "mage", specializationId: "warrior:protection", ruleset: "wow", talents: {}, actions: {},
}), (error) => error instanceof GameDatabaseError);
database.createArenaMatch({
id: "arena-idempotent", bracket: "2v2", mode: "rated-ai", ruleset: "wow", stageId: "warnorken-arena", startedAt: Date.now(),
participants: [
{ actorId: "left", accountId: left.account.id, characterId: "left-priest", team: 0, role: "healer", isBot: false, rating: 0 },
{ actorId: "left-bot", team: 0, role: "damage", isBot: true, rating: 0 },
{ actorId: "right", accountId: right.account.id, characterId: "right-mage", team: 1, role: "damage", isBot: false, rating: 0 },
{ actorId: "right-bot", team: 1, role: "healer", isBot: true, rating: 0 },
],
});
const first = database.settleArenaMatch("arena-idempotent", 0);
const retry = database.settleArenaMatch("arena-idempotent", 0);
assert.equal(first.ratingUpdates.find((update) => update.characterId === "left-priest").after, 32);
assert.deepEqual(retry.ratingUpdates, first.ratingUpdates);
assert.equal(database.getArenaRating(left.account.id, "left-priest", "2v2").rating, 32);
assert.equal(database.getArenaRating(left.account.id, "left-priest", "3v3").rating, 0);
} finally {
database.close();
rmSync(directory, { recursive: true, force: true });
}
});
+115
View File
@@ -0,0 +1,115 @@
export const ARENA_BRACKETS = Object.freeze({
"2v2": Object.freeze({ size: 2, healers: 1, damage: 1 }),
"3v3": Object.freeze({ size: 3, healers: 1, damage: 2 }),
});
export const ARENA_RATING_BANDS = Object.freeze([
Object.freeze({ min: 0, max: 499, title: "Recruit", k: 64, reaction: [1.4, 2.4], healerInterrupt: 0, coordinatedCc: 0.05, defensive: 0.35, error: 0.35 }),
Object.freeze({ min: 500, max: 999, title: "Combatant", k: 48, reaction: [1, 1.8], healerInterrupt: 0.2, coordinatedCc: 0.2, defensive: 0.5, error: 0.28 }),
Object.freeze({ min: 1000, max: 1399, title: "Challenger", k: 40, reaction: [0.75, 1.3], healerInterrupt: 0.4, coordinatedCc: 0.4, defensive: 0.65, error: 0.2 }),
Object.freeze({ min: 1400, max: 1799, title: "Rival", k: 32, reaction: [0.55, 1], healerInterrupt: 0.6, coordinatedCc: 0.6, defensive: 0.75, error: 0.15 }),
Object.freeze({ min: 1800, max: 2099, title: "Duelist", k: 28, reaction: [0.4, 0.8], healerInterrupt: 0.72, coordinatedCc: 0.72, defensive: 0.82, error: 0.12 }),
Object.freeze({ min: 2100, max: 2399, title: "Elite", k: 24, reaction: [0.3, 0.65], healerInterrupt: 0.8, coordinatedCc: 0.8, defensive: 0.88, error: 0.1 }),
Object.freeze({ min: 2400, max: 2500, title: "Gladiator", k: 20, reaction: [0.25, 0.55], healerInterrupt: 0.85, coordinatedCc: 0.85, defensive: 0.9, error: 0.08 }),
]);
export const ARENA_MODES = Object.freeze(["rated-ai", "ranked-pvp"]);
export const ARENA_RULESETS = Object.freeze(["wow", "coa", "rom"]);
export function clampArenaRating(value) {
return Math.max(0, Math.min(2500, Math.round(Number(value) || 0)));
}
export function arenaRatingBand(rating) {
const normalized = clampArenaRating(rating);
return ARENA_RATING_BANDS.find((band) => normalized >= band.min && normalized <= band.max)
?? ARENA_RATING_BANDS[0];
}
function lerp(left, right, amount) {
return left + (right - left) * Math.max(0, Math.min(1, amount));
}
export function arenaAiProfile(rating) {
const normalized = clampArenaRating(rating);
const index = ARENA_RATING_BANDS.findIndex((band) => normalized >= band.min && normalized <= band.max);
const current = ARENA_RATING_BANDS[Math.max(0, index)];
const next = ARENA_RATING_BANDS[Math.min(ARENA_RATING_BANDS.length - 1, Math.max(0, index) + 1)];
const span = Math.max(1, current.max - current.min + 1);
const progress = current === next ? 0 : (normalized - current.min) / span;
return Object.freeze({
rating: normalized,
title: current.title,
reactionMinSeconds: lerp(current.reaction[0], next.reaction[0], progress),
reactionMaxSeconds: lerp(current.reaction[1], next.reaction[1], progress),
healerInterruptChance: lerp(current.healerInterrupt, next.healerInterrupt, progress),
coordinatedCcChance: lerp(current.coordinatedCc, next.coordinatedCc, progress),
correctDefensiveChance: lerp(current.defensive, next.defensive, progress),
minimumErrorChance: lerp(current.error, next.error, progress),
});
}
export function calculateArenaRating({ rating, opponentRating, score }) {
const before = clampArenaRating(rating);
const opposing = clampArenaRating(opponentRating);
const normalizedScore = score === 0.5 ? 0.5 : score > 0 ? 1 : 0;
if (normalizedScore === 0.5) return { before, expected: 0.5, delta: 0, after: before };
const expected = 1 / (1 + 10 ** ((opposing - before) / 400));
const delta = Math.round(arenaRatingBand(before).k * (normalizedScore - expected));
const after = clampArenaRating(before + delta);
return { before, expected, delta: after - before, after };
}
export function arenaDampening(elapsedMs) {
const increases = Math.floor(Math.max(0, Number(elapsedMs) || 0) / 30_000);
return Math.min(1, 0.1 + increases * 0.05);
}
export function applyArenaHealing(rawAmount, elapsedMs) {
return Math.max(0, Math.round(Math.max(0, Number(rawAmount) || 0) * (1 - arenaDampening(elapsedMs))));
}
export function arenaMatchTolerance(waitMs) {
return 100 + Math.floor(Math.max(0, Number(waitMs) || 0) / 10_000) * 100;
}
export function validateArenaComposition(bracket, roles, { allowPartial = false } = {}) {
const definition = ARENA_BRACKETS[bracket];
if (!definition) return { ok: false, code: "invalid_bracket", message: "Choose 2v2 or 3v3." };
if (!Array.isArray(roles) || roles.some((role) => !["healer", "damage"].includes(role))) {
return { ok: false, code: "invalid_arena_role", message: "Rated arenas allow healer and damage specializations only." };
}
const healers = roles.filter((role) => role === "healer").length;
const damage = roles.filter((role) => role === "damage").length;
if (roles.length > definition.size || healers > definition.healers || damage > definition.damage) {
return { ok: false, code: "invalid_composition", message: `${bracket} requires exactly one healer and ${definition.damage} DPS.` };
}
if (!allowPartial && (roles.length !== definition.size || healers !== definition.healers || damage !== definition.damage)) {
return { ok: false, code: "incomplete_composition", message: `${bracket} requires exactly one healer and ${definition.damage} DPS.` };
}
return { ok: true, missing: { healer: definition.healers - healers, damage: definition.damage - damage } };
}
export function arenaDrDuration(baseDurationMs, priorApplications, elapsedSinceEndMs = Infinity) {
const applications = elapsedSinceEndMs >= 15_000 ? 0 : Math.max(0, Math.trunc(priorApplications));
const multiplier = [1, 0.5, 0.25, 0][Math.min(3, applications)];
return { durationMs: Math.round(Math.max(0, baseDurationMs) * multiplier), applications: Math.min(3, applications + 1), multiplier };
}
export function shouldArenaBotChooseCorrectly(successChance, minimumErrorChance, random = Math.random) {
const cappedSuccess = Math.min(1 - Math.max(0, minimumErrorChance), Math.max(0, successChance));
return random() < cappedSuccess;
}
export function assertArenaQueueInput(input) {
if (!ARENA_BRACKETS[input?.bracket]) throw new Error("Choose 2v2 or 3v3.");
if (!ARENA_MODES.includes(input?.mode)) throw new Error("Choose Rated AI or Ranked PvP.");
if (!ARENA_RULESETS.includes(input?.ruleset)) throw new Error("Choose a valid arena ruleset.");
if (typeof input?.characterId !== "string" || !input.characterId.trim()) throw new Error("Choose a character.");
return {
bracket: input.bracket,
mode: input.mode,
ruleset: input.ruleset,
characterId: input.characterId.trim().slice(0, 100),
};
}
+69
View File
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
ARENA_RATING_BANDS,
arenaAiProfile,
arenaDampening,
arenaDrDuration,
arenaMatchTolerance,
calculateArenaRating,
shouldArenaBotChooseCorrectly,
validateArenaComposition,
} from "./arena-rules.mjs";
function seededRandom(seed) {
let value = seed >>> 0;
return () => {
value = (value * 1664525 + 1013904223) >>> 0;
return value / 0x100000000;
};
}
test("arena Elo uses band K values, draws are neutral, and clamps at both ends", () => {
assert.deepEqual(calculateArenaRating({ rating: 1000, opponentRating: 1000, score: 1 }), {
before: 1000, expected: 0.5, delta: 20, after: 1020,
});
assert.equal(calculateArenaRating({ rating: 2499, opponentRating: 2500, score: 1 }).after, 2500);
assert.equal(calculateArenaRating({ rating: 0, opponentRating: 0, score: 0 }).after, 0);
assert.equal(calculateArenaRating({ rating: 1600, opponentRating: 1900, score: 0.5 }).delta, 0);
});
test("arena composition requires exactly one healer and rejects tanks", () => {
assert.equal(validateArenaComposition("2v2", ["healer", "damage"]).ok, true);
assert.equal(validateArenaComposition("3v3", ["healer", "damage", "damage"]).ok, true);
assert.equal(validateArenaComposition("3v3", ["healer"], { allowPartial: true }).missing.damage, 2);
assert.equal(validateArenaComposition("2v2", ["tank", "damage"]).code, "invalid_arena_role");
assert.equal(validateArenaComposition("2v2", ["damage", "damage"]).code, "invalid_composition");
});
test("difficulty, dampening, matchmaking, and DR follow the rated rules", () => {
const gladiator = arenaAiProfile(2500);
assert.equal(gladiator.healerInterruptChance, 0.85);
assert.equal(gladiator.minimumErrorChance, 0.08);
assert.equal(shouldArenaBotChooseCorrectly(1, gladiator.minimumErrorChance, () => 0.93), false);
assert.equal(arenaDampening(0), 0.1);
assert.equal(arenaDampening(540_000), 1);
assert.equal(arenaMatchTolerance(0), 100);
assert.equal(arenaMatchTolerance(30_000), 400);
assert.equal(arenaDrDuration(8000, 2, 0).durationMs, 2000);
assert.equal(arenaDrDuration(8000, 3, 15_000).durationMs, 8000);
});
test("every AI band honors its settings and seeded minimum-error floor", () => {
for (const [index, band] of ARENA_RATING_BANDS.entries()) {
const profile = arenaAiProfile(band.min);
assert.equal(profile.title, band.title);
assert.equal(profile.reactionMinSeconds, band.reaction[0]);
assert.equal(profile.reactionMaxSeconds, band.reaction[1]);
assert.equal(profile.healerInterruptChance, band.healerInterrupt);
assert.equal(profile.coordinatedCcChance, band.coordinatedCc);
assert.equal(profile.correctDefensiveChance, band.defensive);
assert.equal(profile.minimumErrorChance, band.error);
const random = seededRandom(index + 1);
const decisions = Array.from({ length: 4000 }, () => shouldArenaBotChooseCorrectly(1, profile.minimumErrorChance, random));
const errorRate = decisions.filter((decision) => !decision).length / decisions.length;
assert.ok(errorRate >= profile.minimumErrorChance - 0.025, `${band.title} fell below its minimum-error floor`);
assert.ok(errorRate <= profile.minimumErrorChance + 0.025, `${band.title} exceeded its seeded error tolerance`);
}
});
+425
View File
@@ -0,0 +1,425 @@
import { randomBytes, randomUUID } from "node:crypto";
import { GameDatabaseError } from "./database.mjs";
import { ARENA_BRACKETS, arenaMatchTolerance, assertArenaQueueInput, validateArenaComposition } from "./arena-rules.mjs";
import { ARENA_STAGES, chooseArenaStage } from "./arena-stages.mjs";
import { ArenaSimulation } from "./arena-simulation.mjs";
const READY_CHECK_MS = 15_000;
const PVP_BOT_FILL_MS = 30_000;
function average(values) {
return Math.round(values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length));
}
function publicTicket(ticket, now = Date.now()) {
if (!ticket) return { status: "idle" };
return {
status: ticket.status,
ticketId: ticket.id,
joinedAt: ticket.joinedAt,
searchRange: arenaMatchTolerance(now - ticket.joinedAt),
readyDeadline: ticket.lobby?.readyDeadline ?? null,
matchId: ticket.matchId ?? ticket.lobby?.matchId ?? null,
lockedLoadout: ticket.members.find((member) => member.accountId === ticket.ownerAccountId)?.loadout ?? ticket.members[0]?.loadout,
};
}
function roleCapacity(bracket, members) {
const roles = members.map((member) => member.role);
return validateArenaComposition(bracket, roles, { allowPartial: true }).ok;
}
export function createArenaService({ database, random = Math.random, now = () => Date.now(), autoPump = true } = {}) {
const tickets = new Map();
const accountTickets = new Map();
const lobbies = new Map();
const matches = new Map();
const socketTickets = new Map();
const subscribers = new Map();
const previousStageByAccount = new Map();
function broadcast(accountId, message) {
for (const send of subscribers.get(accountId) ?? []) send(message);
}
function broadcastMatch(match, message) {
for (const participant of match.participants) if (participant.accountId) broadcast(participant.accountId, message);
}
function broadcastSnapshot(match, snapshot) {
for (const participant of match.participants) {
if (participant.accountId) broadcast(participant.accountId, {
type: "snapshot",
snapshot: { ...snapshot, controlledActorId: participant.actorId },
});
}
}
function queueState(accountId) {
return publicTicket(accountTickets.get(accountId), now());
}
function arenaState(accountId, characterId = null) {
return {
...database.getArenaState(accountId, characterId),
queue: queueState(accountId),
stages: ARENA_STAGES,
};
}
function resolveMembers(account, input) {
const groupState = database.onlineGroupState(account.id);
const group = groupState.group;
const requested = Array.isArray(input.groupMembers) ? input.groupMembers : [];
if (!group || group.members.length <= 1) {
const loadout = database.arenaLoadoutForQueue(account.id, input.characterId, input.loadoutSlot);
return [{ accountId: account.id, username: account.username, characterId: input.characterId, loadout, role: loadout.role }];
}
if (group.leaderAccountId !== account.id) {
throw new GameDatabaseError("Only the group leader can enter the group into an arena queue.", 403, "arena_leader_required");
}
if (group.members.length > ARENA_BRACKETS[input.bracket].size) {
throw new GameDatabaseError(`That group is too large for ${input.bracket}.`, 409, "arena_group_too_large");
}
return group.members.map((member) => {
if (accountTickets.has(member.accountId)) throw new GameDatabaseError(`${member.username} is already queued.`, 409, "arena_already_queued");
const selection = requested.find((entry) => entry.accountId === member.accountId)
?? (member.accountId === account.id ? input : null);
const characterId = selection?.characterId ?? member.character?.id;
if (!characterId) throw new GameDatabaseError(`${member.username} must select a group character.`, 409, "arena_group_character_required");
const loadout = selection?.loadoutSlot
? database.arenaLoadoutForQueue(member.accountId, characterId, selection.loadoutSlot)
: database.selectedArenaLoadoutForQueue(member.accountId, characterId);
return {
accountId: member.accountId,
username: member.username,
characterId,
loadout,
role: loadout.role,
};
});
}
function enqueue(account, inputValue) {
if (accountTickets.has(account.id)) throw new GameDatabaseError("You are already in an arena queue.", 409, "arena_already_queued");
let input;
try { input = { ...inputValue, ...assertArenaQueueInput(inputValue) }; }
catch (error) { throw new GameDatabaseError(error.message, 400, "invalid_arena_queue"); }
const members = resolveMembers(account, input);
const composition = validateArenaComposition(input.bracket, members.map((member) => member.role), { allowPartial: true });
if (!composition.ok) throw new GameDatabaseError(composition.message, 400, composition.code);
if (members.some((member) => member.loadout.ruleset !== input.ruleset)) {
throw new GameDatabaseError("Every loadout must use the selected ruleset.", 400, "arena_ruleset_mismatch");
}
const ratings = members.map((member) => database.getArenaRating(member.accountId, member.characterId, input.bracket).rating);
const ticket = {
id: `queue-${randomUUID()}`,
ownerAccountId: account.id,
bracket: input.bracket,
mode: input.mode,
ruleset: input.ruleset,
joinedAt: now(),
rating: average(ratings),
members,
status: "queued",
ready: new Set(),
lobby: null,
matchId: null,
};
tickets.set(ticket.id, ticket);
for (const member of members) accountTickets.set(member.accountId, ticket);
if (ticket.mode === "rated-ai") createLobby([[ticket], []], ticket.rating);
else pump();
return publicTicket(ticket, now());
}
function removeTicket(ticket) {
tickets.delete(ticket.id);
for (const member of ticket.members) if (accountTickets.get(member.accountId) === ticket) accountTickets.delete(member.accountId);
}
function leaveQueue(accountId) {
const ticket = accountTickets.get(accountId);
if (!ticket) return { status: "idle" };
if (ticket.status === "matched") {
const match = matches.get(ticket.matchId);
const participant = match?.participants.find((entry) => entry.accountId === accountId);
if (participant) match.simulation.disconnect(participant.actorId);
return publicTicket(ticket, now());
}
if (ticket.lobby) cancelLobby(ticket.lobby, "A player left the ready check.");
else removeTicket(ticket);
return { status: "idle" };
}
function compatible(left, right, timestamp) {
if (left.id === right.id || left.bracket !== right.bracket || left.ruleset !== right.ruleset || left.mode !== right.mode) return false;
const tolerance = Math.max(arenaMatchTolerance(timestamp - left.joinedAt), arenaMatchTolerance(timestamp - right.joinedAt));
return Math.abs(left.rating - right.rating) <= tolerance;
}
function assembleHumanLobby(pool, timestamp) {
if (pool.length < 2) return null;
const bracket = pool[0].bracket;
const teamSize = ARENA_BRACKETS[bracket].size;
for (let anchorIndex = 0; anchorIndex < pool.length; anchorIndex += 1) {
const anchor = pool[anchorIndex];
const compatiblePool = pool.filter((ticket) => compatible(anchor, ticket, timestamp) || ticket === anchor);
const teams = [[], []];
const memberSets = [[], []];
for (const ticket of compatiblePool) {
const side = [0, 1].find((team) => (
memberSets[team].length + ticket.members.length <= teamSize
&& roleCapacity(bracket, [...memberSets[team], ...ticket.members])
));
if (side == null) continue;
teams[side].push(ticket);
memberSets[side].push(...ticket.members);
if (memberSets.every((members) => validateArenaComposition(bracket, members.map((member) => member.role)).ok)) return teams;
}
}
return null;
}
function pump() {
const timestamp = now();
for (const ticket of [...tickets.values()]) {
if (ticket.status === "ready-check" && ticket.lobby?.readyDeadline <= timestamp) cancelLobby(ticket.lobby, "The ready check expired.");
}
const waiting = [...tickets.values()].filter((ticket) => ticket.status === "queued" && ticket.mode === "ranked-pvp");
const pools = new Map();
for (const ticket of waiting) {
const key = `${ticket.bracket}:${ticket.ruleset}`;
const pool = pools.get(key) ?? [];
pool.push(ticket);
pools.set(key, pool);
}
for (const pool of pools.values()) {
while (true) {
const teams = assembleHumanLobby(pool.filter((ticket) => ticket.status === "queued"), timestamp);
if (!teams) break;
createLobby(teams, average(teams.flat().map((ticket) => ticket.rating)));
}
for (const ticket of pool) {
if (ticket.status === "queued" && timestamp - ticket.joinedAt >= PVP_BOT_FILL_MS) createLobby([[ticket], []], ticket.rating);
}
}
}
function botMember(team, role, rating, index) {
return {
actorId: `bot-${randomUUID()}`,
accountId: null,
characterId: null,
name: `${rating >= 2400 ? "Gladiator" : rating >= 1800 ? "Duelist" : rating >= 1000 ? "Challenger" : "Recruit"} ${role === "healer" ? "Medic" : `Fighter ${index + 1}`}`,
team,
role,
isBot: true,
disconnected: false,
rating,
loadout: null,
};
}
function fillTeam(humans, team, bracket, rating) {
const members = humans.map((member) => ({
actorId: `player-${randomUUID()}`,
accountId: member.accountId,
characterId: member.characterId,
name: member.loadout.name || member.username,
team,
role: member.role,
isBot: false,
disconnected: false,
rating: database.getArenaRating(member.accountId, member.characterId, bracket).rating,
loadout: member.loadout,
}));
const composition = validateArenaComposition(bracket, members.map((member) => member.role), { allowPartial: true });
if (!composition.ok) throw new GameDatabaseError(composition.message, 400, composition.code);
for (let index = 0; index < composition.missing.healer; index += 1) members.push(botMember(team, "healer", rating, index));
for (let index = 0; index < composition.missing.damage; index += 1) members.push(botMember(team, "damage", rating, index));
return members;
}
function createLobby(teamTickets, effectiveRating) {
const allTickets = teamTickets.flat();
if (!allTickets.length || allTickets.some((ticket) => ticket.status !== "queued")) return null;
const first = allTickets[0];
const matchId = `arena-${randomUUID()}`;
const humanTeams = teamTickets.map((side) => side.flatMap((ticket) => ticket.members));
const participants = [
...fillTeam(humanTeams[0], 0, first.bracket, effectiveRating),
...fillTeam(humanTeams[1], 1, first.bracket, effectiveRating),
];
const previousStageId = allTickets.map((ticket) => previousStageByAccount.get(ticket.ownerAccountId)).find(Boolean) ?? null;
const stage = chooseArenaStage({ random, previousStageId });
const lobby = {
id: `lobby-${randomUUID()}`,
matchId,
bracket: first.bracket,
mode: first.mode,
ruleset: first.ruleset,
stage,
tickets: allTickets,
participants,
effectiveRating,
readyAccounts: new Set(),
readyDeadline: now() + READY_CHECK_MS,
};
lobbies.set(lobby.id, lobby);
for (const ticket of allTickets) {
ticket.status = "ready-check";
ticket.lobby = lobby;
ticket.matchId = matchId;
for (const member of ticket.members) broadcast(member.accountId, { type: "ready-check", deadline: lobby.readyDeadline, matchId });
}
return lobby;
}
function cancelLobby(lobby, message) {
if (!lobbies.has(lobby.id)) return;
lobbies.delete(lobby.id);
for (const ticket of lobby.tickets) {
ticket.lobby = null;
ticket.matchId = null;
ticket.status = "queued";
ticket.ready.clear();
ticket.joinedAt = now();
for (const member of ticket.members) broadcast(member.accountId, { type: "queue-cancelled", message });
}
}
function ready(accountId) {
const ticket = accountTickets.get(accountId);
const lobby = ticket?.lobby;
if (!ticket || ticket.status !== "ready-check" || !lobby) throw new GameDatabaseError("No arena ready check is active.", 409, "arena_ready_check_missing");
if (lobby.readyDeadline <= now()) {
cancelLobby(lobby, "The ready check expired.");
throw new GameDatabaseError("The arena ready check expired.", 409, "arena_ready_check_expired");
}
lobby.readyAccounts.add(accountId);
ticket.ready.add(accountId);
const humanAccounts = lobby.participants.filter((participant) => participant.accountId).map((participant) => participant.accountId);
if (humanAccounts.every((id) => lobby.readyAccounts.has(id))) startMatch(lobby);
return publicTicket(ticket, now());
}
function startMatch(lobby) {
if (!lobbies.delete(lobby.id)) return;
const startedAt = now();
const stored = {
id: lobby.matchId,
bracket: lobby.bracket,
mode: lobby.mode,
ruleset: lobby.ruleset,
stageId: lobby.stage.id,
startedAt,
participants: lobby.participants,
};
database.createArenaMatch(stored);
const match = { ...stored, stage: lobby.stage, subscribers: new Set(), result: null, simulation: null };
const simulation = new ArenaSimulation({
...stored,
stage: lobby.stage,
startedAt,
random,
onSnapshot: (snapshot) => broadcastSnapshot(match, snapshot),
onResult: (result) => settleMatch(match, result),
});
match.simulation = simulation;
matches.set(match.id, match);
for (const ticket of lobby.tickets) {
ticket.status = "matched";
ticket.lobby = null;
ticket.matchId = match.id;
for (const member of ticket.members) {
previousStageByAccount.set(member.accountId, lobby.stage.id);
const participant = match.participants.find((entry) => entry.accountId === member.accountId);
broadcast(member.accountId, {
type: "match-start", matchId: match.id, stage: lobby.stage,
snapshot: { ...simulation.snapshot(), controlledActorId: participant?.actorId },
});
}
}
simulation.start();
}
function settleMatch(match, result) {
if (match.result) return match.result;
const settlement = result.reason === "void"
? (database.voidArenaMatch(match.id), { ...result, ratingUpdates: [] })
: { ...result, ...database.settleArenaMatch(match.id, result.winnerTeam) };
match.result = settlement;
broadcastMatch(match, { type: "result", result: settlement });
for (const participant of match.participants) {
if (!participant.accountId) continue;
const ticket = accountTickets.get(participant.accountId);
if (ticket?.matchId === match.id) removeTicket(ticket);
}
return settlement;
}
function socketTicket(accountId) {
const token = randomBytes(32).toString("base64url");
socketTickets.set(token, { accountId, expiresAt: now() + 30_000 });
return { ticket: token, expiresAt: now() + 30_000, path: "/ws/arena" };
}
function consumeSocketTicket(token) {
const entry = socketTickets.get(token);
socketTickets.delete(token);
if (!entry || entry.expiresAt <= now()) return null;
return entry.accountId;
}
function subscribe(accountId, send) {
const set = subscribers.get(accountId) ?? new Set();
set.add(send);
subscribers.set(accountId, set);
const ticket = accountTickets.get(accountId);
if (ticket?.matchId) {
const match = matches.get(ticket.matchId);
if (match) {
const participant = match.participants.find((entry) => entry.accountId === accountId);
send({ type: "snapshot", snapshot: { ...match.simulation.snapshot(), controlledActorId: participant?.actorId } });
}
}
return () => {
set.delete(send);
if (!set.size) subscribers.delete(accountId);
const current = accountTickets.get(accountId);
if (current?.status === "matched") {
const match = matches.get(current.matchId);
const participant = match?.participants.find((entry) => entry.accountId === accountId);
if (participant) match.simulation.disconnect(participant.actorId);
}
};
}
function receive(accountId, message) {
const ticket = accountTickets.get(accountId);
const match = matches.get(ticket?.matchId);
if (!match || match.result) return { accepted: false, reason: "match-unavailable" };
const participant = match.participants.find((entry) => entry.accountId === accountId);
if (!participant) return { accepted: false, reason: "spectator" };
return match.simulation.receiveIntent(participant.actorId, message);
}
const interval = autoPump ? setInterval(pump, 250) : null;
interval?.unref?.();
function close() {
if (interval) clearInterval(interval);
for (const match of matches.values()) {
if (!match.result) match.simulation.finish(null, "void");
}
subscribers.clear();
socketTickets.clear();
}
return {
arenaState, queueState, enqueue, leaveQueue, ready, pump,
socketTicket, consumeSocketTicket, subscribe, receive, close,
inspect: () => ({ tickets, lobbies, matches }),
};
}
+87
View File
@@ -0,0 +1,87 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createArenaService } from "./arena-service.mjs";
function fakeDatabase() {
return {
onlineGroupState: () => ({ group: null, invitations: [] }),
arenaLoadoutForQueue: (_accountId, characterId) => ({
slot: 1, name: "Disc", characterId, classId: "priest", specializationId: "priest:discipline",
role: "healer", ruleset: "wow", level: 80, talents: {}, actions: {}, version: 1, selected: true, updatedAt: 1,
}),
selectedArenaLoadoutForQueue: (_accountId, characterId) => ({
slot: 2, name: "Selected", characterId, classId: "warrior", specializationId: "warrior:arms",
role: "damage", ruleset: "wow", level: 80, talents: {}, actions: {}, version: 1, selected: true, updatedAt: 1,
}),
getArenaRating: () => ({ rating: 1200, title: "Challenger" }),
getArenaState: () => ({ characterId: "priest", loadouts: [], ratings: {} }),
createArenaMatch: () => {}, settleArenaMatch: () => ({ ratingUpdates: [] }), voidArenaMatch: () => {},
};
}
test("Rated AI fills immediately and requires the 15 second ready check", () => {
let clock = 1000;
const service = createArenaService({ database: fakeDatabase(), now: () => clock, random: () => 0, autoPump: false });
const account = { id: "account", username: "ArenaPlayer" };
const queued = service.enqueue(account, { characterId: "priest", loadoutSlot: 1, bracket: "2v2", mode: "rated-ai", ruleset: "wow" });
assert.equal(queued.status, "ready-check");
assert.equal(queued.readyDeadline, clock + 15_000);
service.ready(account.id);
const match = [...service.inspect().matches.values()][0];
assert.equal(match.participants.length, 4);
assert.equal(match.participants.filter((participant) => participant.team === 0 && participant.role === "healer").length, 1);
match.simulation.stop();
service.close();
});
test("group leaders queue partial teams with each member's selected loadout", () => {
const database = fakeDatabase();
database.onlineGroupState = () => ({
invitations: [],
group: {
leaderAccountId: "leader",
members: [
{ accountId: "leader", username: "Leader", character: { id: "priest" } },
{ accountId: "member", username: "Member", character: { id: "warrior" } },
],
},
});
const service = createArenaService({ database, now: () => 1000, random: () => 0, autoPump: false });
const queued = service.enqueue({ id: "leader", username: "Leader" }, {
characterId: "priest", loadoutSlot: 1, bracket: "3v3", mode: "rated-ai", ruleset: "wow",
});
assert.equal(queued.status, "ready-check");
service.ready("leader");
assert.equal(service.inspect().matches.size, 0);
service.ready("member");
const match = [...service.inspect().matches.values()][0];
assert.equal(match.participants.filter((participant) => participant.accountId).length, 2);
assert.equal(match.participants.filter((participant) => participant.team === 0 && participant.isBot).length, 1);
match.simulation.stop();
service.close();
});
test("expired ready checks return tickets to matchmaking", () => {
let clock = 1000;
const service = createArenaService({ database: fakeDatabase(), now: () => clock, random: () => 0, autoPump: false });
const account = { id: "account", username: "ArenaPlayer" };
service.enqueue(account, { characterId: "priest", loadoutSlot: 1, bracket: "2v2", mode: "rated-ai", ruleset: "wow" });
clock += 15_001;
service.pump();
assert.equal(service.queueState(account.id).status, "queued");
assert.equal(service.inspect().matches.size, 0);
service.close();
});
test("Ranked PvP permanently fills every open slot after thirty seconds", () => {
let clock = 1000;
const service = createArenaService({ database: fakeDatabase(), now: () => clock, random: () => 0, autoPump: false });
const account = { id: "account", username: "ArenaPlayer" };
assert.equal(service.enqueue(account, { characterId: "priest", loadoutSlot: 1, bracket: "3v3", mode: "ranked-pvp", ruleset: "wow" }).status, "queued");
clock += 30_000;
service.pump();
const lobby = [...service.inspect().lobbies.values()][0];
assert.equal(lobby.participants.length, 6);
assert.equal(lobby.participants.filter((participant) => participant.isBot).length, 5);
service.close();
});
+46
View File
@@ -0,0 +1,46 @@
import type {
ArenaAuthoritativeSnapshot,
ArenaCombatIntent,
ArenaResult,
ArenaRole,
ArenaStageManifest,
} from "../src/game/arenaTypes";
import type { ArenaBracket, ArenaMode, ArenaRuleset } from "../src/game/arenaRules";
export interface ArenaSimulationParticipant {
actorId: string;
accountId?: string | null;
characterId?: string | null;
name: string;
team: 0 | 1;
role: ArenaRole;
isBot: boolean;
disconnected?: boolean;
rating: number;
}
export interface ArenaSimulationOptions {
id: string;
stage: ArenaStageManifest;
bracket: ArenaBracket;
mode: ArenaMode;
ruleset: ArenaRuleset;
participants: readonly ArenaSimulationParticipant[];
random?: () => number;
startedAt?: number;
onSnapshot?: (snapshot: ArenaAuthoritativeSnapshot) => void;
onResult?: (result: Omit<ArenaResult, "ratingUpdates">) => void;
}
export declare const ARENA_TICK_RATE: 20;
export declare const ARENA_SNAPSHOT_RATE: 10;
export declare const ARENA_TICK_MS: 50;
export declare class ArenaSimulation {
constructor(options: ArenaSimulationOptions);
start(): this;
stop(): void;
step(): void;
snapshot(): ArenaAuthoritativeSnapshot;
receiveIntent(actorId: string, intent: ArenaCombatIntent): { accepted: boolean; reason?: string };
}
+427
View File
@@ -0,0 +1,427 @@
import { arenaAiProfile, arenaDampening, arenaDrDuration, applyArenaHealing, shouldArenaBotChooseCorrectly } from "./arena-rules.mjs";
export const ARENA_TICK_RATE = 20;
export const ARENA_SNAPSHOT_RATE = 10;
export const ARENA_TICK_MS = 1000 / ARENA_TICK_RATE;
const ABILITIES = Object.freeze({
attack: Object.freeze({ cooldownMs: 1500, range: 5, damage: 40 }),
strike: Object.freeze({ cooldownMs: 6000, range: 5, damage: 120 }),
"ranged-attack": Object.freeze({ cooldownMs: 2200, range: 30, damage: 52 }),
heal: Object.freeze({ cooldownMs: 0, range: 40, healing: 150, castMs: 1500, resource: 12 }),
interrupt: Object.freeze({ cooldownMs: 15000, range: 8, lockoutMs: 4000 }),
control: Object.freeze({ cooldownMs: 30000, range: 25, controlMs: 6000, drCategory: "incapacitate" }),
defensive: Object.freeze({ cooldownMs: 90000, range: 0, defensiveMs: 8000 }),
});
function distance2d(left, right) {
return Math.hypot(left.position[0] - right.position[0], left.position[2] - right.position[2]);
}
function segmentIntersectsBox(from, to, object) {
if (object.shape !== "box" && object.shape !== "cylinder") return false;
const radiusX = object.size[0] / 2;
const radiusZ = object.size[2] / 2;
const dx = to[0] - from[0];
const dz = to[2] - from[2];
const lengthSquared = dx * dx + dz * dz;
const amount = lengthSquared > 0
? Math.max(0, Math.min(1, ((object.position[0] - from[0]) * dx + (object.position[2] - from[2]) * dz) / lengthSquared))
: 0;
const nearestX = from[0] + dx * amount;
const nearestZ = from[2] + dz * amount;
return Math.abs(nearestX - object.position[0]) <= radiusX
&& Math.abs(nearestZ - object.position[2]) <= radiusZ;
}
function newActor(participant, spawn, teamIndex, arenaCenter) {
const offset = participant.role === "healer" ? -2 : teamIndex * 2;
const facing = Math.atan2(arenaCenter[0] - spawn[0], arenaCenter[2] - spawn[2]);
return {
id: participant.actorId,
accountId: participant.accountId ?? null,
characterId: participant.characterId ?? null,
name: participant.name,
team: participant.team,
role: participant.role,
isBot: Boolean(participant.isBot),
disconnected: Boolean(participant.disconnected),
alive: true,
position: [spawn[0], spawn[1], spawn[2] + offset],
facing,
movement: [0, 0],
health: 1000,
maxHealth: 1000,
resource: 100,
maxResource: 100,
targetActorId: null,
cooldowns: new Map(),
effects: new Map(),
dr: new Map(),
casting: null,
lockoutUntil: 0,
lastSequence: 0,
nextBotDecisionAt: 0,
rating: Number(participant.rating ?? 0),
};
}
export function arenaDynamicObjectState(stage, activeElapsedMs) {
return stage.dynamicObjects.map((definition) => {
if (definition.kind === "starting-elevators") {
const delay = definition.initialDelayMs ?? 0;
const travelMs = definition.activeMs || 1500;
if (activeElapsedMs < delay) return { id: definition.id, state: "raised", progress: 0, collisionEnabled: true };
if (activeElapsedMs < delay + travelMs) {
return { id: definition.id, state: "moving", progress: (activeElapsedMs - delay) / travelMs, collisionEnabled: true };
}
return { id: definition.id, state: "open", progress: 1, collisionEnabled: false };
}
if (definition.kind === "waterfall") {
const start = definition.runtimeStartMs ?? definition.initialDelayRangeMs?.[0] ?? 30000;
const cycle = definition.activeMs + definition.cycleMs;
const time = activeElapsedMs - start;
if (time < -definition.warningMs) return { id: definition.id, state: "open", progress: 0, collisionEnabled: false };
if (time < 0) return { id: definition.id, state: "warning", progress: (definition.warningMs + time) / definition.warningMs, collisionEnabled: false };
const within = time % Math.max(1, cycle);
if (within >= definition.activeMs) {
const remaining = cycle - within;
const warning = remaining <= definition.warningMs;
return { id: definition.id, state: warning ? "warning" : "open", progress: warning ? 1 - remaining / definition.warningMs : 0, collisionEnabled: false };
}
return { id: definition.id, state: "active", progress: within / definition.activeMs, collisionEnabled: true };
}
if (definition.kind === "pipe-knockback") {
const warningStart = Math.max(0, (definition.initialDelayMs ?? 5000) - definition.warningMs);
if (activeElapsedMs < warningStart) return { id: definition.id, state: "closed", progress: 0, collisionEnabled: true };
if (activeElapsedMs < (definition.initialDelayMs ?? 5000)) return { id: definition.id, state: "warning", progress: 1, collisionEnabled: true };
if (activeElapsedMs < (definition.initialDelayMs ?? 5000) + definition.activeMs) return { id: definition.id, state: "active", progress: 1, collisionEnabled: false };
return { id: definition.id, state: "closed", progress: 0, collisionEnabled: true };
}
const delay = definition.initialDelayMs ?? 0;
const time = activeElapsedMs - delay;
const raised = time >= 0 && (definition.cycleMs <= 0 || time % definition.cycleMs < definition.activeMs);
return {
id: definition.id,
state: definition.kind === "fire-cycle" ? (raised ? "active" : "closed") : (raised ? "raised" : "lowered"),
progress: raised ? 1 : 0,
collisionEnabled: definition.kind === "moving-pillars" ? raised : false,
};
});
}
export class ArenaSimulation {
constructor(options) {
this.id = options.id;
this.stage = {
...options.stage,
dynamicObjects: options.stage.dynamicObjects.map((object) => ({ ...object })),
lineOfSight: options.stage.lineOfSight.map((object) => ({ ...object })),
};
this.bracket = options.bracket;
this.mode = options.mode;
this.ruleset = options.ruleset;
this.random = options.random ?? Math.random;
this.onSnapshot = options.onSnapshot ?? (() => {});
this.onResult = options.onResult ?? (() => {});
this.phase = "preparation";
this.tickNumber = 0;
this.startedAt = Number(options.startedAt ?? Date.now());
this.phaseEndsAt = this.startedAt + 10_000;
this.activeStartedAt = null;
this.finished = false;
this.interval = null;
this.actors = new Map();
this.lastMechanicPulse = new Map();
const teamCounts = [0, 0];
const arenaCenter = [
(this.stage.bounds.min[0] + this.stage.bounds.max[0]) / 2,
(this.stage.bounds.min[2] + this.stage.bounds.max[2]) / 2,
];
for (const participant of options.participants) {
const team = participant.team;
const actor = newActor(participant, this.stage.teamSpawns[team], teamCounts[team]++, arenaCenter);
this.actors.set(actor.id, actor);
}
for (const object of this.stage.dynamicObjects) {
if (object.initialDelayRangeMs) {
const [min, max] = object.initialDelayRangeMs;
object.runtimeStartMs = min + this.random() * Math.max(0, max - min);
}
}
}
start() {
if (!this.interval && !this.finished) this.interval = setInterval(() => this.step(Date.now()), ARENA_TICK_MS);
return this;
}
stop() {
if (this.interval) clearInterval(this.interval);
this.interval = null;
}
actor(actorId) {
return this.actors.get(actorId) ?? null;
}
hasLineOfSight(left, right, dynamicStates = null) {
const states = dynamicStates ?? new Map(this.dynamicObjects().map((state) => [state.id, state]));
return !this.stage.lineOfSight.some((object) => {
if (object.dynamicObjectId && !states.get(object.dynamicObjectId)?.collisionEnabled) return false;
return segmentIntersectsBox(left.position, right.position, object);
});
}
receiveIntent(actorId, intent) {
const actor = this.actor(actorId);
if (!actor || actor.isBot || actor.disconnected || !actor.alive || this.phase !== "active") return { accepted: false, reason: "actor-unavailable" };
const sequence = Math.trunc(Number(intent?.sequence));
if (!Number.isSafeInteger(sequence) || sequence <= actor.lastSequence) return { accepted: false, reason: "stale-sequence" };
actor.lastSequence = sequence;
if (intent.type === "movement") {
const x = Math.max(-1, Math.min(1, Number(intent.movement?.x) || 0));
const z = Math.max(-1, Math.min(1, Number(intent.movement?.z) || 0));
const length = Math.hypot(x, z);
actor.movement = length > 1 ? [x / length, z / length] : [x, z];
return { accepted: true };
}
if (intent.type === "facing") {
actor.facing = Number.isFinite(Number(intent.facing)) ? Number(intent.facing) : actor.facing;
return { accepted: true };
}
if (intent.type === "target") {
actor.targetActorId = this.actors.has(intent.targetActorId) ? intent.targetActorId : null;
return { accepted: true };
}
if (intent.type === "ability") return this.useAbility(actor, String(intent.abilityId ?? ""), intent.targetActorId);
return { accepted: false, reason: "unknown-intent" };
}
useAbility(actor, abilityId, targetActorId = null) {
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
const ability = ABILITIES[abilityId];
if (!ability || !actor.alive || now < (actor.cooldowns.get(abilityId) ?? 0) || now < actor.lockoutUntil) return { accepted: false, reason: "ability-unavailable" };
if (actor.effects.get("control")?.endsAt > now) return { accepted: false, reason: "controlled" };
const target = this.actor(targetActorId ?? actor.targetActorId) ?? (abilityId === "defensive" ? actor : null);
if (!target || !target.alive) return { accepted: false, reason: "invalid-target" };
const friendly = target.team === actor.team;
if ((ability.healing && !friendly) || (!ability.healing && abilityId !== "defensive" && friendly)) return { accepted: false, reason: "invalid-target" };
if (ability.range > 0 && distance2d(actor, target) > ability.range) return { accepted: false, reason: "out-of-range" };
if (target !== actor && !this.hasLineOfSight(actor, target)) return { accepted: false, reason: "line-of-sight" };
if (ability.resource && actor.resource < ability.resource) return { accepted: false, reason: "resource" };
if (ability.castMs) {
actor.resource -= ability.resource ?? 0;
actor.casting = { abilityId, targetActorId: target.id, completesAt: now + ability.castMs };
return { accepted: true, casting: true };
}
actor.cooldowns.set(abilityId, now + ability.cooldownMs);
if (ability.damage) this.damage(target, ability.damage);
if (ability.lockoutMs) {
if (!target.casting) return { accepted: true, interrupted: false };
target.casting = null;
target.lockoutUntil = now + ability.lockoutMs;
}
if (ability.controlMs) {
const state = target.dr.get(ability.drCategory) ?? { applications: 0, endedAt: -Infinity };
const dr = arenaDrDuration(ability.controlMs, state.applications, now - state.endedAt);
if (dr.durationMs > 0) target.effects.set("control", { id: "control", endsAt: now + dr.durationMs, drCategory: ability.drCategory });
target.dr.set(ability.drCategory, { applications: dr.applications, endedAt: now + dr.durationMs });
}
if (ability.defensiveMs) actor.effects.set("defensive", { id: "defensive", endsAt: now + ability.defensiveMs });
return { accepted: true };
}
damage(target, amount) {
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
const reduction = target.effects.get("defensive")?.endsAt > now ? 0.5 : 0;
target.health = Math.max(0, target.health - Math.round(amount * (1 - reduction)));
if (target.health <= 0) {
target.alive = false;
target.movement = [0, 0];
target.casting = null;
}
}
dynamicObjects() {
const elapsed = this.activeStartedAt == null ? -1 : this.tickNumber * ARENA_TICK_MS - (this.activeStartedAt - this.startedAt);
return arenaDynamicObjectState(this.stage, elapsed);
}
step() {
if (this.finished) return;
this.tickNumber += 1;
const simulationNow = this.startedAt + this.tickNumber * ARENA_TICK_MS;
if (this.phase === "preparation" && simulationNow >= this.phaseEndsAt) {
this.phase = "active";
this.activeStartedAt = this.phaseEndsAt;
this.phaseEndsAt = null;
}
if (this.phase === "active") {
this.updateActors(simulationNow);
this.applyMechanics(simulationNow);
this.checkResult();
}
if (this.tickNumber % (ARENA_TICK_RATE / ARENA_SNAPSHOT_RATE) === 0) this.onSnapshot(this.snapshot());
}
updateActors(now) {
const deltaSeconds = ARENA_TICK_MS / 1000;
for (const actor of this.actors.values()) {
for (const [effectId, effect] of actor.effects) if (effect.endsAt <= now) actor.effects.delete(effectId);
for (const [abilityId, endsAt] of actor.cooldowns) if (endsAt <= now) actor.cooldowns.delete(abilityId);
actor.resource = Math.min(actor.maxResource, actor.resource + 3 * deltaSeconds);
if (!actor.alive) continue;
if (actor.casting?.completesAt <= now) {
const cast = actor.casting;
actor.casting = null;
if (cast.abilityId === "heal") {
const target = this.actor(cast.targetActorId);
if (target?.alive && target.team === actor.team && distance2d(actor, target) <= ABILITIES.heal.range && this.hasLineOfSight(actor, target)) {
const elapsed = Math.max(0, now - this.activeStartedAt);
target.health = Math.min(target.maxHealth, target.health + applyArenaHealing(ABILITIES.heal.healing, elapsed));
}
}
}
if (!actor.casting && !(actor.effects.get("control")?.endsAt > now)) this.moveActor(actor, deltaSeconds);
if (actor.isBot && now >= actor.nextBotDecisionAt) this.botDecision(actor, now);
}
}
moveActor(actor, deltaSeconds) {
const speed = 7;
const nextX = actor.position[0] + actor.movement[0] * speed * deltaSeconds;
const nextZ = actor.position[2] + actor.movement[1] * speed * deltaSeconds;
actor.position[0] = Math.max(this.stage.bounds.min[0], Math.min(this.stage.bounds.max[0], nextX));
actor.position[2] = Math.max(this.stage.bounds.min[2], Math.min(this.stage.bounds.max[2], nextZ));
}
botDecision(actor, now) {
const rating = actor.rating ?? 0;
const profile = arenaAiProfile(rating);
actor.nextBotDecisionAt = now + (profile.reactionMinSeconds + this.random() * (profile.reactionMaxSeconds - profile.reactionMinSeconds)) * 1000;
const allies = [...this.actors.values()].filter((entry) => entry.team === actor.team && entry.alive);
const enemies = [...this.actors.values()].filter((entry) => entry.team !== actor.team && entry.alive && this.hasLineOfSight(actor, entry));
const target = enemies.sort((left, right) => left.health - right.health)[0];
if (!target) {
actor.movement = [0, 0];
return;
}
actor.targetActorId = target.id;
const lowAlly = allies.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0];
if (actor.role === "healer" && lowAlly.health < lowAlly.maxHealth * 0.86) {
actor.movement = [0, 0];
this.useAbility(actor, "heal", lowAlly.id);
return;
}
const castingHealer = enemies.find((entry) => entry.role === "healer" && entry.casting);
if (castingHealer && shouldArenaBotChooseCorrectly(profile.healerInterruptChance, profile.minimumErrorChance, this.random)) {
this.useAbility(actor, "interrupt", castingHealer.id);
return;
}
if (actor.health < actor.maxHealth * 0.4 && shouldArenaBotChooseCorrectly(profile.correctDefensiveChance, profile.minimumErrorChance, this.random)) {
this.useAbility(actor, "defensive", actor.id);
return;
}
if (shouldArenaBotChooseCorrectly(profile.coordinatedCcChance * 0.25, profile.minimumErrorChance, this.random)) {
const healer = enemies.find((entry) => entry.role === "healer");
if (healer && this.useAbility(actor, "control", healer.id).accepted) return;
}
const distance = distance2d(actor, target);
if (distance > 4.5) {
const dx = target.position[0] - actor.position[0];
const dz = target.position[2] - actor.position[2];
const length = Math.max(0.001, Math.hypot(dx, dz));
actor.movement = [dx / length, dz / length];
if (distance <= 30) this.useAbility(actor, "ranged-attack", target.id);
} else {
actor.movement = [0, 0];
this.useAbility(actor, "strike", target.id);
this.useAbility(actor, "attack", target.id);
}
}
applyMechanics(now) {
const activeElapsed = now - this.activeStartedAt;
const states = new Map(this.dynamicObjects().map((state) => [state.id, state]));
if (this.stage.id === "dalaran-sewers") {
for (const id of ["pipes", "waterfall"]) {
if (states.get(id)?.state !== "active") continue;
const pulseMs = id === "waterfall" ? 1500 : 750;
const previous = this.lastMechanicPulse.get(id) ?? -Infinity;
if (activeElapsed - previous < pulseMs) continue;
this.lastMechanicPulse.set(id, activeElapsed);
for (const actor of this.actors.values()) {
if (!actor.alive || Math.abs(actor.position[0]) > 6 || Math.abs(actor.position[2]) > 14) continue;
const direction = actor.position[2] >= 0 ? 1 : -1;
actor.position[2] += direction * (id === "waterfall" ? 4 : 2);
}
}
}
if (this.stage.id === "ring-of-valor" && states.get("fire")?.state === "active") {
const previous = this.lastMechanicPulse.get("fire") ?? -Infinity;
if (activeElapsed - previous >= 1000) {
this.lastMechanicPulse.set("fire", activeElapsed);
for (const actor of this.actors.values()) {
if (actor.alive && Math.abs(actor.position[2]) > 25) this.damage(actor, 35);
}
}
}
}
checkResult() {
const alive = [0, 1].map((team) => [...this.actors.values()].some((actor) => actor.team === team && actor.alive));
if (alive[0] && alive[1]) return;
const winnerTeam = alive[0] === alive[1] ? null : alive[0] ? 0 : 1;
this.finish(winnerTeam, winnerTeam == null ? "simultaneous-elimination" : "elimination");
}
finish(winnerTeam, reason) {
if (this.finished) return;
this.finished = true;
this.phase = reason === "void" ? "void" : "finished";
this.stop();
this.onSnapshot(this.snapshot());
this.onResult({ matchId: this.id, winnerTeam, draw: winnerTeam == null, reason });
}
disconnect(actorId) {
const actor = this.actor(actorId);
if (!actor) return false;
actor.disconnected = true;
actor.isBot = true;
actor.nextBotDecisionAt = 0;
return true;
}
snapshot() {
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
const elapsed = this.activeStartedAt == null ? 0 : Math.max(0, now - this.activeStartedAt);
return {
matchId: this.id,
tick: this.tickNumber,
serverTime: now,
phase: this.phase,
phaseEndsAt: this.phaseEndsAt,
stageId: this.stage.id,
bracket: this.bracket,
mode: this.mode,
ruleset: this.ruleset,
dampening: this.phase === "active" || this.phase === "finished" ? arenaDampening(elapsed) : 0.1,
actors: [...this.actors.values()].map((actor) => ({
id: actor.id, name: actor.name, team: actor.team, role: actor.role,
isBot: actor.isBot, disconnected: actor.disconnected, alive: actor.alive,
position: [...actor.position], facing: actor.facing,
health: actor.health, maxHealth: actor.maxHealth,
resource: Math.round(actor.resource), maxResource: actor.maxResource,
targetActorId: actor.targetActorId,
cooldowns: Object.fromEntries([...actor.cooldowns].map(([id, endsAt]) => [id, Math.max(0, endsAt - now)])),
effects: [
...[...actor.effects.values()].map((effect) => ({ id: effect.id, remainingMs: Math.max(0, effect.endsAt - now) })),
...(actor.casting ? [{ id: `casting:${actor.casting.abilityId}`, remainingMs: Math.max(0, actor.casting.completesAt - now) }] : []),
],
})),
dynamicObjects: this.dynamicObjects(),
};
}
}
+42
View File
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ARENA_STAGES } from "./arena-stages.mjs";
import { ArenaSimulation, ARENA_TICK_RATE, arenaDynamicObjectState } from "./arena-simulation.mjs";
function participants() {
return [
{ actorId: "left-healer", name: "Left Healer", team: 0, role: "healer", isBot: false, rating: 1200 },
{ actorId: "left-damage", name: "Left Damage", team: 0, role: "damage", isBot: true, rating: 1200 },
{ actorId: "right-healer", name: "Right Healer", team: 1, role: "healer", isBot: true, rating: 1200 },
{ actorId: "right-damage", name: "Right Damage", team: 1, role: "damage", isBot: true, rating: 1200 },
];
}
test("authoritative simulation seals ten seconds, runs 20 Hz, and rejects duplicate packets", () => {
const stage = ARENA_STAGES.find((entry) => entry.id === "warnorken-arena");
const simulation = new ArenaSimulation({ id: "sim", stage, bracket: "2v2", mode: "rated-ai", ruleset: "wow", participants: participants(), random: () => 0 });
for (let index = 0; index < ARENA_TICK_RATE * 10 - 1; index += 1) simulation.step();
assert.equal(simulation.snapshot().phase, "preparation");
simulation.step();
assert.equal(simulation.snapshot().phase, "active");
assert.equal(simulation.receiveIntent("left-healer", { sequence: 1, type: "movement", movement: { x: 1, z: 0 } }).accepted, true);
assert.equal(simulation.receiveIntent("left-healer", { sequence: 1, type: "movement", movement: { x: 1, z: 0 } }).reason, "stale-sequence");
const before = simulation.actor("left-healer").position[0];
simulation.step();
assert.ok(simulation.actor("left-healer").position[0] > before);
});
test("Dalaran and Ring of Valor mechanics synchronize collision state", () => {
const dalaran = ARENA_STAGES.find((entry) => entry.id === "dalaran-sewers");
const warning = arenaDynamicObjectState(dalaran, 27_000).find((entry) => entry.id === "waterfall");
const active = arenaDynamicObjectState(dalaran, 31_000).find((entry) => entry.id === "waterfall");
assert.equal(warning.state, "warning");
assert.equal(active.state, "active");
assert.equal(active.collisionEnabled, true);
const ring = ARENA_STAGES.find((entry) => entry.id === "ring-of-valor");
assert.equal(arenaDynamicObjectState(ring, 20_000).find((entry) => entry.id === "elevators").state, "raised");
assert.equal(arenaDynamicObjectState(ring, 21_000).find((entry) => entry.id === "elevators").state, "moving");
assert.equal(arenaDynamicObjectState(ring, 22_500).find((entry) => entry.id === "elevators").state, "open");
assert.equal(arenaDynamicObjectState(ring, 24_000).find((entry) => entry.id === "pillars-north").state, "lowered");
assert.equal(arenaDynamicObjectState(ring, 26_000).find((entry) => entry.id === "pillars-north").state, "raised");
});
+27
View File
@@ -0,0 +1,27 @@
const HEALER_SPECIALIZATIONS = new Set([
"paladin:holy", "priest:discipline", "priest:holy", "shaman:restoration", "druid:restoration",
"witch-doctor:brewing", "bloodmage:fleshweaver", "chronomancer:time", "cultist:heretic",
"starcaller:moon-priest", "sun-cleric:blessings", "tinker:invention", "venomancer:vizier",
"primalist:life",
]);
const TANK_SPECIALIZATIONS = new Set([
"warrior:protection", "paladin:protection", "death-knight:blood", "druid:feral-bear",
"felsworn:infernal", "witch-hunter:black-knight", "knight-of-xoroth:defiance", "guardian:vanguard",
"templar:oathkeeper", "bloodmage:eternal", "cultist:dreadnought", "starcaller:moon-guard",
"sun-cleric:valkyrie", "venomancer:fortitude", "reaper:domination", "primalist:mountain-king",
"runemaster:riftblade",
]);
export function arenaRoleForSpecialization(classId, specializationId, ruleset) {
const normalizedClass = String(classId ?? "").trim().toLowerCase();
const normalizedSpec = String(specializationId ?? "").trim().toLowerCase();
if (!normalizedClass || !normalizedSpec || !normalizedSpec.startsWith(`${normalizedClass}:`)) return null;
if (ruleset === "rom") {
if (normalizedSpec === `${normalizedClass}:role-healer`) return "healer";
if (normalizedSpec === `${normalizedClass}:role-damage`) return "damage";
return normalizedSpec.endsWith(":role-tank") ? "tank" : null;
}
if (TANK_SPECIALIZATIONS.has(normalizedSpec)) return "tank";
return HEALER_SPECIALIZATIONS.has(normalizedSpec) ? "healer" : "damage";
}
+35
View File
@@ -0,0 +1,35 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const source = JSON.parse(readFileSync(fileURLToPath(new URL("../shared/arena-stages.json", import.meta.url)), "utf8"));
function normalizeStage(stage) {
return Object.freeze({
...stage,
assets: Object.freeze([
{ id: "visual", url: `${stage.assetBaseUrl}/visual.glb` },
{ id: "collision", url: `${stage.assetBaseUrl}/collision.glb` },
{ id: "navigation", url: `${stage.assetBaseUrl}/navigation.glb` },
]),
});
}
export const ARENA_STAGES = Object.freeze(source.stages.map(normalizeStage));
export const VALIDATED_ARENA_STAGES = Object.freeze(ARENA_STAGES.filter((stage) => (
stage.validation.traversal
&& stage.validation.collision
&& stage.validation.spawnSafety
&& stage.validation.lineOfSight
&& stage.validation.performance
)));
export function arenaStageById(stageId) {
return ARENA_STAGES.find((stage) => stage.id === stageId) ?? null;
}
export function chooseArenaStage({ random = Math.random, previousStageId = null, allowUnvalidated = false } = {}) {
const pool = allowUnvalidated ? ARENA_STAGES : VALIDATED_ARENA_STAGES;
if (!pool.length) throw new Error("No validated arena stages are available.");
const alternatives = pool.length > 1 ? pool.filter((stage) => stage.id !== previousStageId) : pool;
return alternatives[Math.floor(random() * alternatives.length)];
}
+24
View File
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ARENA_STAGES, VALIDATED_ARENA_STAGES, chooseArenaStage } from "./arena-stages.mjs";
test("the rated pool contains all eight validated arenas", () => {
assert.equal(ARENA_STAGES.length, 8);
assert.deepEqual(VALIDATED_ARENA_STAGES.map((stage) => stage.id), ARENA_STAGES.map((stage) => stage.id));
});
test("rated selection covers the pool uniformly and avoids the previous arena", () => {
const choices = VALIDATED_ARENA_STAGES.map((_, index, pool) => (
chooseArenaStage({ random: () => (index + 0.25) / pool.length })
));
assert.deepEqual(choices.map((stage) => stage.id), VALIDATED_ARENA_STAGES.map((stage) => stage.id));
const previousStageId = VALIDATED_ARENA_STAGES[0].id;
for (let index = 0; index < VALIDATED_ARENA_STAGES.length - 1; index += 1) {
const selected = chooseArenaStage({
previousStageId,
random: () => (index + 0.25) / (VALIDATED_ARENA_STAGES.length - 1),
});
assert.notEqual(selected.id, previousStageId);
}
});
+342
View File
@@ -8,6 +8,8 @@ import {
normalizeManastormAdminConfig,
stampManastormReviewMetadata,
} from "./manastorm-config.mjs";
import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena-rules.mjs";
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
const scrypt = promisify(scryptCallback);
@@ -115,7 +117,64 @@ export function openGameDatabase(options = {}) {
) STRICT;
CREATE INDEX IF NOT EXISTS online_group_invites_invitee_idx
ON online_group_invites(invitee_account_id, status, expires_at);
CREATE TABLE IF NOT EXISTS arena_loadouts (
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
character_id TEXT NOT NULL,
slot INTEGER NOT NULL CHECK (slot BETWEEN 1 AND 10),
name TEXT NOT NULL,
class_id TEXT NOT NULL,
specialization_id TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('healer', 'damage')),
ruleset TEXT NOT NULL CHECK (ruleset IN ('wow', 'coa', 'rom')),
level INTEGER NOT NULL CHECK (level = 80),
talents_json TEXT NOT NULL,
actions_json TEXT NOT NULL,
version INTEGER NOT NULL,
selected INTEGER NOT NULL CHECK (selected IN (0, 1)),
updated_at INTEGER NOT NULL,
PRIMARY KEY (account_id, character_id, slot)
) STRICT;
CREATE INDEX IF NOT EXISTS arena_loadouts_character_idx
ON arena_loadouts(account_id, character_id, selected);
CREATE TABLE IF NOT EXISTS arena_ratings (
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
character_id TEXT NOT NULL,
bracket TEXT NOT NULL CHECK (bracket IN ('2v2', '3v3')),
rating INTEGER NOT NULL CHECK (rating BETWEEN 0 AND 2500),
updated_at INTEGER NOT NULL,
PRIMARY KEY (account_id, character_id, bracket)
) STRICT;
CREATE TABLE IF NOT EXISTS arena_matches (
id TEXT PRIMARY KEY,
bracket TEXT NOT NULL CHECK (bracket IN ('2v2', '3v3')),
mode TEXT NOT NULL CHECK (mode IN ('rated-ai', 'ranked-pvp')),
ruleset TEXT NOT NULL CHECK (ruleset IN ('wow', 'coa', 'rom')),
stage_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active', 'settled', 'void')),
winner_team INTEGER CHECK (winner_team IN (0, 1)),
started_at INTEGER NOT NULL,
settled_at INTEGER
) STRICT;
CREATE TABLE IF NOT EXISTS arena_match_participants (
match_id TEXT NOT NULL REFERENCES arena_matches(id) ON DELETE CASCADE,
actor_id TEXT NOT NULL,
account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
character_id TEXT,
team INTEGER NOT NULL CHECK (team IN (0, 1)),
role TEXT NOT NULL CHECK (role IN ('healer', 'damage')),
is_bot INTEGER NOT NULL CHECK (is_bot IN (0, 1)),
disconnected INTEGER NOT NULL DEFAULT 0 CHECK (disconnected IN (0, 1)),
rating_before INTEGER,
rating_delta INTEGER,
rating_after INTEGER,
PRIMARY KEY (match_id, actor_id),
UNIQUE (match_id, account_id, character_id)
) STRICT;
CREATE INDEX IF NOT EXISTS arena_matches_status_idx ON arena_matches(status, started_at);
`);
database.prepare(`
UPDATE arena_matches SET status = 'void', settled_at = ? WHERE status = 'active'
`).run(Date.now());
database.prepare(`
INSERT OR IGNORE INTO manastorm_admin_config (
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
@@ -152,6 +211,23 @@ export function openGameDatabase(options = {}) {
WHERE id = 1 AND revision = ?
RETURNING revision, updated_at
`),
arenaLoadouts: database.prepare(`
SELECT * FROM arena_loadouts WHERE account_id = ? AND character_id = ? ORDER BY slot
`),
arenaLoadout: database.prepare(`
SELECT * FROM arena_loadouts WHERE account_id = ? AND character_id = ? AND slot = ?
`),
selectedArenaLoadout: database.prepare(`
SELECT * FROM arena_loadouts WHERE account_id = ? AND character_id = ?
ORDER BY selected DESC, slot ASC LIMIT 1
`),
arenaRating: database.prepare(`
SELECT rating, updated_at FROM arena_ratings WHERE account_id = ? AND character_id = ? AND bracket = ?
`),
upsertArenaRating: database.prepare(`
INSERT INTO arena_ratings (account_id, character_id, bracket, rating, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(account_id, character_id, bracket) DO UPDATE SET rating = excluded.rating, updated_at = excluded.updated_at
`),
};
function issueSession(accountId) {
@@ -488,6 +564,9 @@ export function openGameDatabase(options = {}) {
id,
name,
classId,
specializationId: typeof character.specializationId === "string"
? character.specializationId.slice(0, 100)
: null,
categoryId: String(character.categoryId ?? "wow").slice(0, 20),
raceId: String(character.raceId ?? "human").slice(0, 50),
gender: character.gender === "female" ? "female" : "male",
@@ -560,6 +639,260 @@ export function openGameDatabase(options = {}) {
return onlineGroupState(accountId);
}
function arenaCharacter(accountId, characterId) {
const save = getCloudSave(accountId);
const characters = Array.isArray(save.data?.characters) ? save.data.characters : [];
const character = characters.find((entry) => String(entry?.id ?? "") === characterId);
if (!character) throw new GameDatabaseError("That character is not in your roster.", 404, "arena_character_not_found");
return character;
}
function parseArenaLoadout(row) {
return {
slot: Number(row.slot),
name: row.name,
characterId: row.character_id,
classId: row.class_id,
specializationId: row.specialization_id,
role: row.role,
ruleset: row.ruleset,
level: 80,
talents: parseStoredJson(row.talents_json, {}),
actions: parseStoredJson(row.actions_json, {}),
version: Number(row.version),
selected: Boolean(row.selected),
updatedAt: Number(row.updated_at),
};
}
function validateArenaRecord(value, name, maxEntries, maxLength) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new GameDatabaseError(`${name} must be an object.`, 400, `invalid_arena_${name.toLowerCase()}`);
}
const entries = Object.entries(value);
if (entries.length > maxEntries) {
throw new GameDatabaseError(`${name} has too many entries.`, 400, `invalid_arena_${name.toLowerCase()}`);
}
const json = JSON.stringify(value);
if (json.length > maxLength) {
throw new GameDatabaseError(`${name} is too large.`, 400, `invalid_arena_${name.toLowerCase()}`);
}
return json;
}
function putArenaLoadout(accountId, characterIdInput, slotInput, input) {
const characterId = String(characterIdInput ?? "").trim().slice(0, 100);
const slot = Math.trunc(Number(slotInput));
if (!characterId || !Number.isInteger(slot) || slot < 1 || slot > 10) {
throw new GameDatabaseError("Choose loadout slot 1 through 10.", 400, "invalid_arena_loadout_slot");
}
const character = arenaCharacter(accountId, characterId);
const classId = String(input?.classId ?? character.classId ?? "").trim().toLowerCase().slice(0, 50);
if (!classId || classId !== String(character.classId ?? "").trim().toLowerCase()) {
throw new GameDatabaseError("The loadout specialization must belong to this character's class.", 400, "arena_class_mismatch");
}
const ruleset = String(input?.ruleset ?? character.categoryId ?? "wow").trim().toLowerCase();
if (!["wow", "coa", "rom"].includes(ruleset)) {
throw new GameDatabaseError("Choose a valid arena ruleset.", 400, "invalid_arena_ruleset");
}
const specializationId = String(input?.specializationId ?? "").trim().toLowerCase().slice(0, 100);
const role = arenaRoleForSpecialization(classId, specializationId, ruleset);
if (role === "tank") throw new GameDatabaseError("Tank specializations cannot enter rated arenas.", 400, "arena_tank_forbidden");
if (!role || !["healer", "damage"].includes(role)) {
throw new GameDatabaseError("Choose a valid healer or damage specialization.", 400, "invalid_arena_specialization");
}
if (input?.role && input.role !== role) {
throw new GameDatabaseError("The selected role does not match that specialization.", 400, "arena_role_mismatch");
}
const name = String(input?.name ?? `Loadout ${slot}`).trim().slice(0, 32);
if (!name) throw new GameDatabaseError("Name this PvP loadout.", 400, "invalid_arena_loadout_name");
const talentsJson = validateArenaRecord(input?.talents ?? {}, "Talents", 100, 16 * 1024);
const actionsJson = validateArenaRecord(input?.actions ?? {}, "Actions", 120, 24 * 1024);
const previous = statements.arenaLoadout.get(accountId, characterId, slot);
const version = Number(previous?.version ?? 0) + 1;
const selected = input?.selected === true || !statements.arenaLoadouts.all(accountId, characterId).length;
const now = Date.now();
database.exec("BEGIN IMMEDIATE");
try {
if (selected) database.prepare("UPDATE arena_loadouts SET selected = 0 WHERE account_id = ? AND character_id = ?").run(accountId, characterId);
database.prepare(`
INSERT INTO arena_loadouts (
account_id, character_id, slot, name, class_id, specialization_id, role, ruleset,
level, talents_json, actions_json, version, selected, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 80, ?, ?, ?, ?, ?)
ON CONFLICT(account_id, character_id, slot) DO UPDATE SET
name = excluded.name, class_id = excluded.class_id,
specialization_id = excluded.specialization_id, role = excluded.role,
ruleset = excluded.ruleset, level = 80, talents_json = excluded.talents_json,
actions_json = excluded.actions_json, version = excluded.version,
selected = excluded.selected, updated_at = excluded.updated_at
`).run(
accountId, characterId, slot, name, classId, specializationId, role, ruleset,
talentsJson, actionsJson, version, selected ? 1 : 0, now,
);
database.exec("COMMIT");
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
return parseArenaLoadout(statements.arenaLoadout.get(accountId, characterId, slot));
}
function deleteArenaLoadout(accountId, characterIdInput, slotInput) {
const characterId = String(characterIdInput ?? "").trim().slice(0, 100);
const slot = Math.trunc(Number(slotInput));
arenaCharacter(accountId, characterId);
const previous = statements.arenaLoadout.get(accountId, characterId, slot);
if (!previous) throw new GameDatabaseError("That PvP loadout does not exist.", 404, "arena_loadout_not_found");
database.exec("BEGIN IMMEDIATE");
try {
database.prepare("DELETE FROM arena_loadouts WHERE account_id = ? AND character_id = ? AND slot = ?").run(accountId, characterId, slot);
if (previous.selected) {
database.prepare(`
UPDATE arena_loadouts SET selected = 1
WHERE account_id = ? AND character_id = ? AND slot = (
SELECT MIN(slot) FROM arena_loadouts WHERE account_id = ? AND character_id = ?
)
`).run(accountId, characterId, accountId, characterId);
}
database.exec("COMMIT");
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
return { ok: true };
}
function getArenaRating(accountId, characterId, bracket) {
if (!["2v2", "3v3"].includes(bracket)) throw new GameDatabaseError("Choose 2v2 or 3v3.", 400, "invalid_arena_bracket");
const row = statements.arenaRating.get(accountId, characterId, bracket);
const rating = clampArenaRating(row?.rating ?? 0);
return { bracket, rating, title: arenaRatingBand(rating).title, updatedAt: row ? Number(row.updated_at) : null };
}
function getArenaState(accountId, characterIdInput = null) {
const cloud = getCloudSave(accountId);
const roster = Array.isArray(cloud.data?.characters) ? cloud.data.characters : [];
const requestedId = String(characterIdInput ?? "").trim();
const character = requestedId ? arenaCharacter(accountId, requestedId) : roster[0] ?? null;
if (!character) return { characterId: null, loadouts: [], ratings: { "2v2": null, "3v3": null } };
const characterId = String(character.id);
return {
characterId,
loadouts: statements.arenaLoadouts.all(accountId, characterId).map(parseArenaLoadout),
ratings: {
"2v2": getArenaRating(accountId, characterId, "2v2"),
"3v3": getArenaRating(accountId, characterId, "3v3"),
},
};
}
function arenaLoadoutForQueue(accountId, characterId, slot) {
arenaCharacter(accountId, characterId);
const row = statements.arenaLoadout.get(accountId, characterId, Math.trunc(Number(slot)));
if (!row) throw new GameDatabaseError("Create that PvP loadout before queueing.", 409, "arena_loadout_required");
return parseArenaLoadout(row);
}
function selectedArenaLoadoutForQueue(accountId, characterId) {
arenaCharacter(accountId, characterId);
const row = statements.selectedArenaLoadout.get(accountId, characterId);
if (!row) throw new GameDatabaseError("Create and select a PvP loadout before queueing.", 409, "arena_loadout_required");
return parseArenaLoadout(row);
}
function createArenaMatch(match) {
database.exec("BEGIN IMMEDIATE");
try {
database.prepare(`
INSERT INTO arena_matches (id, bracket, mode, ruleset, stage_id, status, winner_team, started_at, settled_at)
VALUES (?, ?, ?, ?, ?, 'active', NULL, ?, NULL)
`).run(match.id, match.bracket, match.mode, match.ruleset, match.stageId, match.startedAt);
const insertParticipant = database.prepare(`
INSERT INTO arena_match_participants (
match_id, actor_id, account_id, character_id, team, role, is_bot, disconnected,
rating_before, rating_delta, rating_after
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
`);
for (const participant of match.participants) {
const before = participant.isBot
? clampArenaRating(participant.rating)
: getArenaRating(participant.accountId, participant.characterId, match.bracket).rating;
insertParticipant.run(
match.id, participant.actorId, participant.accountId ?? null, participant.characterId ?? null,
participant.team, participant.role, participant.isBot ? 1 : 0, participant.disconnected ? 1 : 0, before,
);
}
database.exec("COMMIT");
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
return match;
}
function settleArenaMatch(matchId, winnerTeam) {
database.exec("BEGIN IMMEDIATE");
try {
const match = database.prepare("SELECT * FROM arena_matches WHERE id = ?").get(matchId);
if (!match) throw new GameDatabaseError("Arena match not found.", 404, "arena_match_not_found");
const rows = database.prepare("SELECT * FROM arena_match_participants WHERE match_id = ? ORDER BY actor_id").all(matchId);
if (match.status === "settled") {
database.exec("COMMIT");
return {
matchId,
winnerTeam: match.winner_team == null ? null : Number(match.winner_team),
draw: match.winner_team == null,
ratingUpdates: rows.filter((row) => !row.is_bot).map((row) => ({
characterId: row.character_id, bracket: match.bracket, before: Number(row.rating_before),
delta: Number(row.rating_delta), after: Number(row.rating_after), title: arenaRatingBand(row.rating_after).title,
})),
};
}
if (match.status === "void") throw new GameDatabaseError("That arena match was voided.", 409, "arena_match_void");
const normalizedWinner = winnerTeam === 0 || winnerTeam === 1 ? winnerTeam : null;
const teamRatings = [0, 1].map((team) => {
const teamRows = rows.filter((row) => Number(row.team) === team);
return Math.round(teamRows.reduce((sum, row) => sum + Number(row.rating_before ?? 0), 0) / Math.max(1, teamRows.length));
});
const updates = [];
for (const row of rows) {
if (row.is_bot || !row.account_id || !row.character_id) continue;
const team = Number(row.team);
const score = normalizedWinner == null ? 0.5 : normalizedWinner === team ? 1 : 0;
const update = calculateArenaRating({
rating: Number(row.rating_before), opponentRating: teamRatings[team === 0 ? 1 : 0], score,
});
database.prepare(`
UPDATE arena_match_participants SET rating_delta = ?, rating_after = ?
WHERE match_id = ? AND actor_id = ? AND rating_after IS NULL
`).run(update.delta, update.after, matchId, row.actor_id);
statements.upsertArenaRating.run(row.account_id, row.character_id, match.bracket, update.after, Date.now());
updates.push({
characterId: row.character_id, bracket: match.bracket, before: update.before,
delta: update.delta, after: update.after, title: arenaRatingBand(update.after).title,
});
}
const settledAt = Date.now();
database.prepare(`
UPDATE arena_matches SET status = 'settled', winner_team = ?, settled_at = ? WHERE id = ? AND status = 'active'
`).run(normalizedWinner, settledAt, matchId);
database.exec("COMMIT");
return { matchId, winnerTeam: normalizedWinner, draw: normalizedWinner == null, ratingUpdates: updates };
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
}
function voidArenaMatch(matchId) {
const settledAt = Date.now();
const result = database.prepare(`
UPDATE arena_matches SET status = 'void', settled_at = ? WHERE id = ? AND status = 'active'
`).run(settledAt, matchId);
return { ok: true, changed: Number(result.changes) > 0 };
}
return {
databasePath,
register,
@@ -579,6 +912,15 @@ export function openGameDatabase(options = {}) {
updateOnlineGroupMember,
startOnlineGroupActivity,
clearOnlineGroupActivity,
getArenaState,
putArenaLoadout,
deleteArenaLoadout,
arenaLoadoutForQueue,
selectedArenaLoadoutForQueue,
getArenaRating,
createArenaMatch,
settleArenaMatch,
voidArenaMatch,
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
close: () => database.close(),
};
+3 -2
View File
@@ -169,14 +169,15 @@ test("launches players-only and AI-filled group activities only when every membe
database.respondToOnlineGroupInvite(member.id, invite.id, true);
database.updateOnlineGroupMember(leader.id, {
id: "leader-character", name: "Leadwell", classId: "warrior", raceId: "human", gender: "male", level: 20,
id: "leader-character", name: "Leadwell", classId: "warrior", specializationId: "warrior:protection", raceId: "human", gender: "male", level: 20,
}, "tank");
assert.equal(database.onlineGroupState(leader.id).group.members[0].character.specializationId, "warrior:protection");
assert.throws(() => database.startOnlineGroupActivity(leader.id, {
type: "dungeon", contentId: "wailing-caverns", fillWithAi: true, partySize: 5,
}), (error) => error instanceof GameDatabaseError && error.code === "group_not_ready");
database.updateOnlineGroupMember(member.id, {
id: "member-character", name: "Mendwell", classId: "priest", raceId: "human", gender: "female", level: 20,
id: "member-character", name: "Mendwell", classId: "priest", specializationId: "priest:holy", raceId: "human", gender: "female", level: 20,
}, "healer");
const playersOnly = database.startOnlineGroupActivity(leader.id, {
type: "dungeon", contentId: "wailing-caverns", fillWithAi: false, partySize: 5,
+51 -2
View File
@@ -3,8 +3,10 @@ import { createServer } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
import { createArenaService } from "./arena-service.mjs";
import { publicManastormAdminConfig } from "./manastorm-config.mjs";
import { createOnlineSessionManager } from "./online-session.mjs";
import { upgradeArenaWebSocket } from "./websocket.mjs";
const MIME_TYPES = new Map([
[".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"],
@@ -151,6 +153,7 @@ export function createGameServer(options = {}) {
const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
const gameDatabase = options.database ?? openGameDatabase(options);
const onlineSessions = createOnlineSessionManager();
const arenaService = createArenaService({ database: gameDatabase });
const allowAuthRequest = createAuthLimiter();
gameDatabase.pruneExpiredSessions();
@@ -168,7 +171,7 @@ export function createGameServer(options = {}) {
response.writeHead(204, {
...cors,
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, OPTIONS",
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, DELETE, OPTIONS",
"Access-Control-Max-Age": "86400",
});
response.end();
@@ -264,6 +267,45 @@ export function createGameServer(options = {}) {
json(response, 200, onlineSessions.sync(account, groupSnapshot, body));
return;
}
if (url.pathname === "/api/arena/state" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, arenaService.arenaState(account.id, url.searchParams.get("characterId")));
return;
}
const arenaLoadoutMatch = /^\/api\/arena\/loadouts\/(\d+)$/.exec(url.pathname);
if (arenaLoadoutMatch && request.method === "PUT") {
const account = gameDatabase.authenticate(bearerToken(request));
const body = await readJson(request, 64 * 1024);
json(response, 200, gameDatabase.putArenaLoadout(account.id, body.characterId, arenaLoadoutMatch[1], body));
return;
}
if (arenaLoadoutMatch && request.method === "DELETE") {
const account = gameDatabase.authenticate(bearerToken(request));
const body = await readJson(request, 16 * 1024);
json(response, 200, gameDatabase.deleteArenaLoadout(account.id, body.characterId, arenaLoadoutMatch[1]));
return;
}
if (url.pathname === "/api/arena/queue" && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
const body = await readJson(request, 128 * 1024);
json(response, 201, arenaService.enqueue(account, body));
return;
}
if (url.pathname === "/api/arena/queue" && request.method === "DELETE") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, arenaService.leaveQueue(account.id));
return;
}
if (url.pathname === "/api/arena/ready" && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, arenaService.ready(account.id));
return;
}
if (url.pathname === "/api/arena/socket-ticket" && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 201, arenaService.socketTicket(account.id));
return;
}
if (url.pathname === "/api/manastorm-config" && request.method === "GET") {
const current = gameDatabase.getManastormConfig();
json(response, 200, {
@@ -327,11 +369,18 @@ export function createGameServer(options = {}) {
}
});
server.on("upgrade", (request, socket, head) => {
if (!upgradeArenaWebSocket(request, socket, head, arenaService)) {
socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
}
});
server.on("close", () => {
onlineSessions.clear();
arenaService.close();
gameDatabase.close();
});
return { server, database: gameDatabase, staticDir, contentDir };
return { server, database: gameDatabase, arenaService, staticDir, contentDir };
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+63
View File
@@ -147,6 +147,69 @@ test("serves the game and authenticated cloud-save API", async () => {
assert.equal(memberSession.players.length, 2);
assert.equal(memberSession.events[0].targetActorId, "boss");
const leftGroup = await fetch(`${origin}/api/online-group/leave`, {
method: "POST",
headers: { Authorization: `Bearer ${auth.token}` },
});
assert.equal(leftGroup.status, 200);
const arenaCharacter = {
id: "web-healer", name: "Mendara", classId: "priest", categoryId: "wow",
specializationId: "priest:discipline", raceId: "human", gender: "female", level: 80,
talentRanks: {}, actionBindings: {}, inventory: [], equipment: {},
};
const arenaSave = await fetch(`${origin}/api/cloud-save`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
body: JSON.stringify({ data: { version: 1, characters: [arenaCharacter] } }),
});
assert.equal(arenaSave.status, 200);
const loadout = await fetch(`${origin}/api/arena/loadouts/1`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
body: JSON.stringify({
characterId: arenaCharacter.id, name: "Discipline PvP", classId: "priest",
specializationId: "priest:discipline", role: "healer", ruleset: "wow",
talents: {}, actions: {}, selected: true,
}),
});
assert.equal(loadout.status, 200);
assert.equal((await loadout.json()).level, 80);
const arenaState = await (await fetch(`${origin}/api/arena/state?characterId=${arenaCharacter.id}`, {
headers: { Authorization: `Bearer ${auth.token}` },
})).json();
assert.equal(arenaState.stages.length, 8);
assert.equal(arenaState.stages.every((stage) => stage.validation.status === "validated"), true);
const socketTicket = await (await fetch(`${origin}/api/arena/socket-ticket`, {
method: "POST",
headers: { Authorization: `Bearer ${auth.token}` },
})).json();
const socket = new WebSocket(`${origin.replace("http:", "ws:")}${socketTicket.path}?ticket=${socketTicket.ticket}`);
const messages = [];
socket.addEventListener("message", (event) => messages.push(JSON.parse(String(event.data))));
await new Promise((resolve, reject) => {
socket.addEventListener("open", resolve, { once: true });
socket.addEventListener("error", reject, { once: true });
});
const arenaQueue = await fetch(`${origin}/api/arena/queue`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
body: JSON.stringify({ characterId: arenaCharacter.id, loadoutSlot: 1, bracket: "2v2", mode: "rated-ai", ruleset: "wow" }),
});
assert.equal(arenaQueue.status, 201);
assert.equal((await arenaQueue.json()).status, "ready-check");
const arenaReady = await fetch(`${origin}/api/arena/ready`, {
method: "POST",
headers: { Authorization: `Bearer ${auth.token}` },
});
assert.equal(arenaReady.status, 200);
assert.equal((await arenaReady.json()).status, "matched");
await new Promise((resolve) => setTimeout(resolve, 25));
assert.equal(messages.some((message) => message.type === "connected"), true);
assert.equal(messages.some((message) => message.type === "ready-check"), true);
assert.equal(messages.some((message) => message.type === "match-start"), true);
socket.close();
const gmRegistration = await fetch(`${origin}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
+119
View File
@@ -0,0 +1,119 @@
import { createHash } from "node:crypto";
const WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
function frame(opcode, payload = Buffer.alloc(0)) {
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
let header;
if (body.length < 126) {
header = Buffer.from([0x80 | opcode, body.length]);
} else if (body.length <= 0xffff) {
header = Buffer.allocUnsafe(4);
header[0] = 0x80 | opcode;
header[1] = 126;
header.writeUInt16BE(body.length, 2);
} else {
header = Buffer.allocUnsafe(10);
header[0] = 0x80 | opcode;
header[1] = 127;
header.writeBigUInt64BE(BigInt(body.length), 2);
}
return Buffer.concat([header, body]);
}
export function upgradeArenaWebSocket(request, socket, head, arenaService) {
const url = new URL(request.url ?? "/", "http://localhost");
if (url.pathname !== "/ws/arena") return false;
const key = String(request.headers["sec-websocket-key"] ?? "");
const version = String(request.headers["sec-websocket-version"] ?? "");
const accountId = arenaService.consumeSocketTicket(url.searchParams.get("ticket") ?? "");
if (!accountId || !key || version !== "13") {
socket.end("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
return true;
}
const accept = createHash("sha1").update(`${key}${WEBSOCKET_GUID}`).digest("base64");
socket.write([
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${accept}`,
"\r\n",
].join("\r\n"));
let buffered = Buffer.from(head ?? Buffer.alloc(0));
let closed = false;
const send = (message) => {
if (!closed && socket.writable) socket.write(frame(0x1, JSON.stringify(message)));
};
const unsubscribe = arenaService.subscribe(accountId, send);
function close(code = 1000, reason = "") {
if (closed) return;
closed = true;
const reasonBytes = Buffer.from(String(reason).slice(0, 100));
const body = Buffer.allocUnsafe(2 + reasonBytes.length);
body.writeUInt16BE(code, 0);
reasonBytes.copy(body, 2);
if (socket.writable) socket.end(frame(0x8, body));
unsubscribe();
}
function parse() {
while (buffered.length >= 2) {
const first = buffered[0];
const second = buffered[1];
const final = Boolean(first & 0x80);
const opcode = first & 0x0f;
const masked = Boolean(second & 0x80);
let length = second & 0x7f;
let offset = 2;
if (!final || !masked) return close(1002, "Invalid client frame");
if (length === 126) {
if (buffered.length < 4) return;
length = buffered.readUInt16BE(2);
offset = 4;
} else if (length === 127) {
if (buffered.length < 10) return;
const largeLength = buffered.readBigUInt64BE(2);
if (largeLength > 65536n) return close(1009, "Frame too large");
length = Number(largeLength);
offset = 10;
}
if (length > 65536) return close(1009, "Frame too large");
if (buffered.length < offset + 4 + length) return;
const mask = buffered.subarray(offset, offset + 4);
const payload = Buffer.from(buffered.subarray(offset + 4, offset + 4 + length));
buffered = buffered.subarray(offset + 4 + length);
for (let index = 0; index < payload.length; index += 1) payload[index] ^= mask[index % 4];
if (opcode === 0x8) return close();
if (opcode === 0x9) {
if (socket.writable) socket.write(frame(0xa, payload));
continue;
}
if (opcode !== 0x1) return close(1003, "Text messages only");
try {
const message = JSON.parse(payload.toString("utf8"));
const result = arenaService.receive(accountId, message);
if (!result.accepted) send({ type: "intent-rejected", sequence: message?.sequence, reason: result.reason });
} catch {
send({ type: "protocol-error", message: "Arena messages must be valid JSON." });
}
}
}
socket.on("data", (chunk) => {
if (closed) return;
buffered = Buffer.concat([buffered, chunk]);
parse();
});
socket.on("error", () => close(1011, "Socket error"));
socket.on("close", () => {
if (!closed) {
closed = true;
unsubscribe();
}
});
if (buffered.length) parse();
send({ type: "connected", queue: arenaService.queueState(accountId) });
return true;
}
+156
View File
@@ -0,0 +1,156 @@
{
"schemaVersion": 1,
"stages": [
{
"id": "nagrand-arena",
"name": "Nagrand Arena",
"sourceMap": "PVPZone01",
"mapId": 559,
"assetBaseUrl": "/assets/game/arenas/nagrand-arena",
"bounds": { "min": [-75, 8, -60], "max": [75, 26, 50] },
"teamSpawns": [[-43.41, 13.4, -42.04], [42.03, 14.5, -44.44]],
"preparationGates": [[-35, 14, -38], [34, 15, -40]],
"spectatorPositions": [[0, 35, 42], [0, 35, -54]],
"shadowSight": [[0, 14, 18], [0, 14, -18]],
"lineOfSight": [
{ "id": "north-pillar", "shape": "cylinder", "position": [0, 14, -12], "size": [8.4, 12, 8.4] },
{ "id": "south-pillar", "shape": "cylinder", "position": [0, 14, 12], "size": [8.4, 12, 8.4] }
],
"dynamicObjects": [],
"validation": { "status": "validated", "traversal": true, "collision": true, "spawnSafety": true, "lineOfSight": true, "performance": true, "notes": ["Original WMO converted; rated gameplay component and spawn anchors validated."] }
},
{
"id": "blades-edge-arena",
"name": "Blade's Edge Arena",
"sourceMap": "bladesedgearena",
"mapId": 562,
"assetBaseUrl": "/assets/game/arenas/blades-edge-arena",
"bounds": { "min": [-18, 27, -68], "max": [78, 83, 68] },
"teamSpawns": [[29.2, 50.7, -51.49], [31.13, 54.4, 49.51]],
"preparationGates": [[29, 51, -44], [31, 54, 42]],
"spectatorPositions": [[-10, 75, 0], [70, 75, 0]],
"shadowSight": [[10, 55, 0], [50, 55, 0]],
"lineOfSight": [
{ "id": "west-pillar", "shape": "box", "position": [20, 45, 0], "size": [6, 18, 6] },
{ "id": "east-pillar", "shape": "box", "position": [45, 45, 0], "size": [6, 18, 6] }
],
"dynamicObjects": [],
"validation": { "status": "validated", "traversal": true, "collision": true, "spawnSafety": true, "lineOfSight": true, "performance": true, "notes": ["Original WMO converted; rated bridge component and spawn anchors validated."] }
},
{
"id": "ruins-of-lordaeron",
"name": "Ruins of Lordaeron",
"sourceMap": "PVPLordaeron",
"mapId": 572,
"assetBaseUrl": "/assets/game/arenas/ruins-of-lordaeron",
"bounds": { "min": [-62, 26, -108], "max": [54, 37, 78] },
"teamSpawns": [[-8.68, 30.1, -73.96], [-5.08, 27.6, 57.28]],
"preparationGates": [[-8, 30, -65], [-5, 28, 49]],
"spectatorPositions": [[48, 45, -15], [-55, 45, -15]],
"shadowSight": [[18, 29, -15], [-26, 29, -15]],
"lineOfSight": [
{ "id": "central-tomb", "shape": "box", "position": [-4, 28, -15], "size": [12, 9, 14] }
],
"dynamicObjects": [],
"validation": { "status": "validated", "traversal": true, "collision": true, "spawnSafety": true, "lineOfSight": true, "performance": true, "notes": ["Original WMO converted; rated courtyard component and spawn anchors validated."] }
},
{
"id": "dalaran-sewers",
"name": "Dalaran Sewers",
"sourceMap": "DalaranArena",
"mapId": 617,
"assetBaseUrl": "/assets/game/arenas/dalaran-sewers",
"bounds": { "min": [-52, 0, -68], "max": [52, 32, 40] },
"teamSpawns": [[-12.41, 20.1, -63.05], [21.79, 20.2, 25.15]],
"preparationGates": [[-10, 20, -55], [20, 20, 18]],
"spectatorPositions": [[-45, 31, -14], [45, 31, -14]],
"shadowSight": [[-18, 20, -14], [18, 20, -14]],
"lineOfSight": [
{ "id": "central-waterfall", "shape": "box", "position": [19, 18, -9], "size": [6, 12, 4], "dynamicObjectId": "waterfall" }
],
"dynamicObjects": [
{ "id": "pipes", "kind": "pipe-knockback", "initialDelayMs": 5000, "warningMs": 3000, "activeMs": 1500, "cycleMs": 0 },
{ "id": "waterfall", "kind": "waterfall", "initialDelayRangeMs": [30000, 60000], "warningMs": 5000, "activeMs": 30000, "cycleMs": 30000 }
],
"validation": { "status": "validated", "traversal": true, "collision": true, "spawnSafety": true, "lineOfSight": true, "performance": true, "notes": ["Original WMO and connected gameplay navmesh validated with pipe and waterfall cycles."] }
},
{
"id": "ring-of-valor",
"name": "Ring of Valor",
"sourceMap": "OrgrimmarArena",
"mapId": 618,
"assetBaseUrl": "/assets/game/arenas/ring-of-valor",
"bounds": { "min": [-58, 20, -40], "max": [58, 45, 42] },
"teamSpawns": [[0, 27, 25], [0, 27, -25]],
"preparationGates": [[0, 27, 20], [0, 27, -20]],
"spectatorPositions": [[-52, 50, 0], [52, 50, 0]],
"shadowSight": [[-25, 27, 0], [25, 27, 0]],
"lineOfSight": [
{ "id": "north-pillar", "shape": "box", "position": [0.8, 24, -23.3], "size": [9, 9, 9], "dynamicObjectId": "pillars-north" },
{ "id": "south-pillar", "shape": "box", "position": [0.8, 24, 21], "size": [9, 9, 9], "dynamicObjectId": "pillars-north" },
{ "id": "west-pillar", "shape": "box", "position": [-37.8, 23, -1.7], "size": [9, 9, 9], "dynamicObjectId": "pillars-south" },
{ "id": "east-pillar", "shape": "box", "position": [40.8, 23, -1.7], "size": [9, 9, 9], "dynamicObjectId": "pillars-south" }
],
"dynamicObjects": [
{ "id": "elevators", "kind": "starting-elevators", "initialDelayMs": 20500, "warningMs": 0, "activeMs": 1500, "cycleMs": 0 },
{ "id": "fire", "kind": "fire-cycle", "initialDelayMs": 5000, "warningMs": 0, "activeMs": 20000, "cycleMs": 25000 },
{ "id": "pillars-north", "kind": "moving-pillars", "initialDelayMs": 25000, "warningMs": 0, "activeMs": 25000, "cycleMs": 50000 },
{ "id": "pillars-south", "kind": "moving-pillars", "initialDelayMs": 50000, "warningMs": 0, "activeMs": 25000, "cycleMs": 50000 }
],
"validation": { "status": "validated", "traversal": true, "collision": true, "spawnSafety": true, "lineOfSight": true, "performance": true, "notes": ["Original WMO and connected floor navmesh validated with elevator, fire, and alternating pillar cycles."] }
},
{
"id": "warnorken-arena",
"name": "Warnorken Arena",
"sourceMap": "warnorken_arena",
"mapId": 129,
"assetBaseUrl": "/assets/game/arenas/warnorken-arena",
"bounds": { "min": [-47, -24, -47], "max": [47, 25, 47] },
"teamSpawns": [[-42.7, -21.75, 14.12], [41.3, -21.65, -6.58]],
"preparationGates": [[-35, -21.5, 12], [34, -21.5, -5]],
"spectatorPositions": [[0, 19, 42], [0, 19, -42]],
"shadowSight": [[0, -21.5, 20], [0, -21.5, -20]],
"lineOfSight": [
{ "id": "central-columns", "shape": "box", "position": [0, -21.5, 0], "size": [8, 10, 8] }
],
"dynamicObjects": [],
"validation": { "status": "validated", "traversal": true, "collision": true, "spawnSafety": true, "lineOfSight": true, "performance": true, "notes": ["Validated Runewaker package; arena-specific spawn and LOS anchors registered."] }
},
{
"id": "black-rook-hold-arena",
"name": "Black Rook Hold Arena",
"sourceMap": "BlackrookHoldArena",
"mapId": 1784,
"assetBaseUrl": "/assets/game/arenas/black-rook-hold-arena",
"bounds": { "min": [-72, -8, -228], "max": [130, 28, -128] },
"teamSpawns": [[-66.098564, 8.416428, -201.852448], [123.158585, 9.916428, -219.909592]],
"preparationGates": [[-42, 8.4, -201], [98, 9.9, -218]],
"spectatorPositions": [[25, 25, -150], [25, 25, -224]],
"shadowSight": [[15, 9, -181], [15, 9, -215]],
"lineOfSight": [
{ "id": "west-ruin", "shape": "box", "position": [-5, 9, -188], "size": [10, 12, 7] },
{ "id": "east-ruin", "shape": "box", "position": [46, 9, -208], "size": [10, 12, 7] }
],
"dynamicObjects": [],
"validation": { "status": "validated", "traversal": true, "collision": true, "spawnSafety": true, "lineOfSight": true, "performance": true, "notes": ["Micro01 and PlatformTest restored; rated component extracted from the source navigation package."] }
},
{
"id": "ashamanes-fall",
"name": "Ashamane's Fall",
"sourceMap": "valsharaharena",
"mapId": 1785,
"assetBaseUrl": "/assets/game/arenas/ashamanes-fall",
"bounds": { "min": [14, -4, 2], "max": [58, 16, 36] },
"teamSpawns": [[18.223099, -0.480263, 32.034554], [55.023094, -0.480263, 7.123436]],
"preparationGates": [[25, -0.5, 27], [48, -0.5, 12]],
"spectatorPositions": [[20, 13, 6], [55, 13, 33]],
"shadowSight": [[29, -0.5, 13], [45, -0.5, 27]],
"lineOfSight": [
{ "id": "north-tree", "shape": "cylinder", "position": [31, -1, 24], "size": [3.5, 10, 3.5] },
{ "id": "south-tree", "shape": "cylinder", "position": [44, -1, 15], "size": [3.5, 10, 3.5] }
],
"dynamicObjects": [],
"validation": { "status": "validated", "traversal": true, "collision": true, "spawnSafety": true, "lineOfSight": true, "performance": true, "notes": ["All sixteen ADT WMO placements restored; rated gameplay component rebuilt and extracted."] }
}
]
}
+4
View File
@@ -14,6 +14,8 @@ const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module)
const DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen })));
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
const ManastormSelectScreen = lazy(() => import("./ui/ManastormSelectScreen").then((module) => ({ default: module.ManastormSelectScreen })));
const ArenaSelectScreen = lazy(() => import("./ui/ArenaSelectScreen").then((module) => ({ default: module.ArenaSelectScreen })));
const ArenaRuntime = lazy(() => import("./ArenaRuntime"));
const GmAdminScreen = lazy(() => import("./ui/GmAdminScreen").then((module) => ({ default: module.GmAdminScreen })));
function ShellTransition() {
@@ -72,6 +74,8 @@ export default function App() {
else if (phase === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
else if (phase === "manastorms") screen = <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
else if (phase === "arenas") screen = <Suspense fallback={<ShellTransition />}><ArenaSelectScreen /></Suspense>;
else if (phase === "arena-game") screen = <Suspense fallback={<ShellTransition />}><ArenaRuntime /></Suspense>;
else if (phase === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><GmAdminScreen /></Suspense>;
else if (phase === "game") screen = <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
const requiresRuntimeCatalog = !["login", "characters", "create-character"].includes(phase);
+281
View File
@@ -0,0 +1,281 @@
import { Clone } from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { Suspense, useEffect, useMemo, useRef, type RefObject } from "react";
import { FrontSide, Group, Mesh, Vector3 } from "three";
import { selectedCharacter, useShellStore } from "./app/shellStore";
import { CharacterModel } from "./avatar/CharacterModel";
import { ProceduralAvatar } from "./avatar/ProceduralAvatar";
import { classById } from "./app/characterCatalog";
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./game/equipment";
import { useArenaStore } from "./game/arenaStore";
import { arenaStageById } from "./game/arenaStages";
import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes";
import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF";
function StageEnvironment({ stage }: { stage: ArenaStageManifest }) {
const visual = stage.assets.find((asset) => asset.id === "visual")?.url;
return visual ? <LoadedStage url={visual} /> : <ProceduralStage stage={stage} />;
}
function LoadedStage({ url }: { url: string }) {
const gltf = useGameGLTF(`${url}?arena-runtime=1`);
useEffect(() => {
gltf.scene.traverse((object) => {
if (!(object instanceof Mesh)) return;
const materials = Array.isArray(object.material) ? object.material : [object.material];
for (const material of materials) {
material.side = FrontSide;
material.needsUpdate = true;
}
});
}, [gltf.scene]);
return <Clone object={gltf.scene} />;
}
function ProceduralStage({ stage }: { stage: ArenaStageManifest }) {
const width = stage.bounds.max[0] - stage.bounds.min[0];
const depth = stage.bounds.max[2] - stage.bounds.min[2];
const centerX = (stage.bounds.max[0] + stage.bounds.min[0]) / 2;
const centerZ = (stage.bounds.max[2] + stage.bounds.min[2]) / 2;
return (
<group>
<mesh position={[centerX, stage.bounds.min[1], centerZ]} receiveShadow>
<boxGeometry args={[width, 0.5, depth]} />
<meshStandardMaterial color="#252b27" roughness={0.9} />
</mesh>
{stage.lineOfSight.map((object) => (
<mesh key={object.id} position={object.position as [number, number, number]} castShadow receiveShadow>
{object.shape === "cylinder"
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} />
: <boxGeometry args={object.size as [number, number, number]} />}
<meshStandardMaterial color="#4e5146" roughness={0.78} />
</mesh>
))}
</group>
);
}
function DynamicStageObjects({ stage, objects }: { stage: ArenaStageManifest; objects: readonly ArenaDynamicObjectSnapshot[] }) {
const states = useMemo(() => new Map(objects.map((object) => [object.id, object])), [objects]);
const definitions = useMemo(() => new Map(stage.dynamicObjects.map((object) => [object.id, object])), [stage]);
const fire = states.get("fire");
const pipes = states.get("pipes");
const elevators = states.get("elevators");
return (
<group>
{stage.lineOfSight.filter((object) => object.dynamicObjectId).map((object) => {
const state = states.get(object.dynamicObjectId!);
const definition = definitions.get(object.dynamicObjectId!);
if (!state || !definition) return null;
const waterfall = definition.kind === "waterfall";
if (waterfall && state.state === "open") return null;
const raised = state.state === "raised" || state.state === "active";
const movingOffset = definition.kind === "moving-pillars" && !raised ? object.size[1] * 0.8 : 0;
const color = waterfall ? (state.state === "warning" ? "#9fcaf0" : "#4aa9ef") : "#9a7447";
return (
<mesh key={object.id} position={[object.position[0], object.position[1] - movingOffset, object.position[2]]} castShadow receiveShadow>
{object.shape === "cylinder"
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} />
: <boxGeometry args={object.size as [number, number, number]} />}
<meshStandardMaterial color={color} emissive={waterfall ? color : "#000000"} emissiveIntensity={waterfall ? 0.35 : 0} transparent={waterfall} opacity={waterfall ? 0.62 : 0.9} />
</mesh>
);
})}
{fire?.state === "active" && [-31, 31].map((z) => (
<mesh key={z} position={[0, stage.bounds.min[1] + 0.6, z]}>
<boxGeometry args={[70, 1.2, 4]} />
<meshStandardMaterial color="#ff7a21" emissive="#ff3b0b" emissiveIntensity={1.8} transparent opacity={0.72} />
</mesh>
))}
{pipes && (pipes.state === "warning" || pipes.state === "active") && stage.teamSpawns.map((spawn, index) => (
<mesh key={index} position={[spawn[0], spawn[1] + 1.3, spawn[2] + (index === 0 ? 8 : -8)]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[pipes.state === "active" ? 2.2 : 1.1, 0.4, pipes.state === "active" ? 10 : 4, 12]} />
<meshStandardMaterial color={pipes.state === "active" ? "#73c8ff" : "#d9e9f6"} emissive="#4b9dcc" emissiveIntensity={0.8} transparent opacity={0.7} />
</mesh>
))}
{elevators && stage.teamSpawns.map((spawn, index) => {
const descent = elevators.state === "open" ? 1 : elevators.state === "moving" ? elevators.progress : 0;
return (
<mesh key={index} position={[spawn[0], spawn[1] - 0.5 - descent * 7, spawn[2]]} receiveShadow>
<cylinderGeometry args={[4.5, 4.5, 1, 24]} />
<meshStandardMaterial color="#6b5944" metalness={0.45} roughness={0.5} />
</mesh>
);
})}
</group>
);
}
function ArenaActor({ actor, local, character, movement, stage }: { actor: ArenaActorSnapshot; local: boolean; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; stage: ArenaStageManifest }) {
const group = useRef<Group>(null);
useFrame((_, delta) => {
const node = group.current;
if (!node) return;
if (local) {
node.position.x = Math.max(stage.bounds.min[0], Math.min(stage.bounds.max[0], node.position.x + movement.current[0] * 7 * delta));
node.position.z = Math.max(stage.bounds.min[2], Math.min(stage.bounds.max[2], node.position.z + movement.current[1] * 7 * delta));
}
node.position.lerp(new Vector3(...actor.position), Math.min(1, delta * (local ? 4 : 14)));
node.rotation.y = actor.facing;
});
const color = actor.team === 0 ? "#58b9ff" : "#e45b55";
const cosmeticEquipment = useMemo(
() => character ? equipmentItemsForOwner(character.equipment, character.inventory, PLAYER_EQUIPMENT_OWNER_ID) : [],
[character],
);
return (
<group ref={group} position={actor.position as [number, number, number]} rotation={[0, actor.facing, 0]} visible={actor.alive}>
{local && character ? (
<CharacterModel identity={character} equipment={cosmeticEquipment} fallback={<ProceduralAvatar accent={classById(character.classId).color} />} />
) : (
<group>
<mesh position={[0, 0.9, 0]} castShadow><capsuleGeometry args={[0.45, 1, 6, 12]} /><meshStandardMaterial color={color} roughness={0.55} /></mesh>
<mesh position={[0, 1.85, 0]} castShadow><sphereGeometry args={[0.34, 14, 10]} /><meshStandardMaterial color={actor.role === "healer" ? "#e8df8b" : color} /></mesh>
</group>
)}
<pointLight position={[0, 1.2, 0]} color={color} intensity={0.75} distance={3} />
</group>
);
}
function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage: ArenaStageManifest }) {
const target = useMemo(() => new Vector3(), []);
const desired = useMemo(() => new Vector3(), []);
useFrame(({ camera }, delta) => {
if (!actor) return;
const spawn = stage.teamSpawns[actor.team];
const centerX = (stage.bounds.min[0] + stage.bounds.max[0]) / 2;
const centerZ = (stage.bounds.min[2] + stage.bounds.max[2]) / 2;
const directionX = centerX - spawn[0];
const directionZ = centerZ - spawn[2];
const length = Math.max(0.001, Math.hypot(directionX, directionZ));
target.set(actor.position[0], actor.position[1] + 1.2, actor.position[2]);
desired.set(actor.position[0] - directionX / length * 5, actor.position[1] + 7.5, actor.position[2] - directionZ / length * 5);
camera.position.lerp(desired, Math.min(1, delta * 5));
camera.lookAt(target);
});
return null;
}
function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]> }) {
return (
<Canvas shadows dpr={[1, 1.35]} camera={{ fov: 52, near: 0.1, far: 1200, position: [0, 16, 24] }}>
<color attach="background" args={["#111815"]} />
<fog attach="fog" args={["#111815", 55, 240]} />
<hemisphereLight args={["#c9d8cd", "#101713", 1.55]} />
<directionalLight castShadow position={[24, 42, 18]} intensity={2.2} color="#f1d6a2" />
<GameGltfLoaderLifecycle>
<Suspense fallback={null}><StageEnvironment stage={stage} /></Suspense>
<DynamicStageObjects stage={stage} objects={dynamicObjects} />
{actors.map((actor) => <ArenaActor key={actor.id} actor={actor} local={actor.id === localActor?.id} character={character} movement={movement} stage={stage} />)}
<ArenaCamera actor={localActor} stage={stage} />
</GameGltfLoaderLifecycle>
</Canvas>
);
}
function TeamFrames({ actors, team }: { actors: readonly ArenaActorSnapshot[]; team: 0 | 1 }) {
return (
<div className={`arena-team-frames arena-team-frames--${team}`}>
{actors.filter((actor) => actor.team === team).map((actor) => (
<button key={actor.id} type="button" className={!actor.alive ? "is-dead" : ""} onClick={() => useArenaStore.getState().intent({ type: "target", targetActorId: actor.id })}>
<span><strong>{actor.name}</strong><small>{actor.isBot ? "AI" : "Player"} / {actor.role}</small></span>
<i><b style={{ width: `${actor.health / actor.maxHealth * 100}%` }} /></i>
<em>{actor.health}</em>
</button>
))}
</div>
);
}
export default function ArenaRuntime() {
const snapshot = useArenaStore((state) => state.snapshot);
const result = useArenaStore((state) => state.result);
const intent = useArenaStore((state) => state.intent);
const leave = useArenaStore((state) => state.leave);
const load = useArenaStore((state) => state.load);
const session = useShellStore((state) => state.session);
const character = useShellStore(selectedCharacter);
const stage = snapshot ? arenaStageById(snapshot.stageId) : null;
const localActor = snapshot?.actors.find((actor) => actor.id === (snapshot.controlledActorId ?? "practice-player")) ?? null;
const movementRef = useRef<[number, number]>([0, 0]);
useEffect(() => {
if (snapshot || !character) return;
if (!session?.accessToken) {
useArenaStore.setState({ error: "Offline Practice ended when the game reloaded. Start a new practice match.", queue: { status: "idle" } });
useShellStore.setState({ phase: "arenas" });
return;
}
void load(session, character).then(() => {
if (useArenaStore.getState().queue.status !== "matched") useShellStore.setState({ phase: "arenas" });
});
}, [character, load, session, snapshot]);
useEffect(() => {
const pressed = new Set<string>();
const movement = () => {
const next: [number, number] = [
Number(pressed.has("KeyD") || pressed.has("ArrowRight")) - Number(pressed.has("KeyA") || pressed.has("ArrowLeft")),
Number(pressed.has("KeyS") || pressed.has("ArrowDown")) - Number(pressed.has("KeyW") || pressed.has("ArrowUp")),
];
movementRef.current = next;
intent({ type: "movement", movement: { x: next[0], z: next[1] } });
};
const down = (event: KeyboardEvent) => {
if (["KeyW", "KeyA", "KeyS", "KeyD", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.code)) {
pressed.add(event.code);
movement();
}
if (event.code === "Digit1") intent({ type: "ability", abilityId: "attack" });
if (event.code === "Digit2") intent({ type: "ability", abilityId: "strike" });
if (event.code === "Digit3") intent({ type: "ability", abilityId: "interrupt" });
if (event.code === "Digit4") intent({ type: "ability", abilityId: "control" });
if (event.code === "Digit5") intent({ type: "ability", abilityId: "defensive" });
if (event.code === "Digit6") intent({ type: "ability", abilityId: "heal" });
};
const up = (event: KeyboardEvent) => {
if (pressed.delete(event.code)) movement();
};
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
};
}, [intent]);
const exit = async () => {
await leave(session);
useShellStore.setState({ phase: "arenas" });
};
if (!snapshot || !stage) return <div className="world-transition"><span>HM</span><strong>Entering arena</strong><small>Waiting for the authoritative match snapshot...</small></div>;
const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null;
return (
<main className="arena-runtime">
<div className="arena-world"><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} /></div>
<header className="arena-match-header">
<span>{stage.name}</span><strong>{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}</strong><em>Dampening {Math.round(snapshot.dampening * 100)}%</em>
</header>
<TeamFrames actors={snapshot.actors} team={0} />
<TeamFrames actors={snapshot.actors} team={1} />
{snapshot.phase === "preparation" && <div className="arena-countdown"><small>Gates sealed</small><strong>{seconds}</strong><span>Prepare your opening</span></div>}
<nav className="arena-action-bar" aria-label="Arena abilities">
{["attack", "strike", "interrupt", "control", "defensive", "heal"].map((ability, index) => (
<button key={ability} type="button" onClick={() => intent({ type: "ability", abilityId: ability })}><b>{index + 1}</b><span>{ability}</span></button>
))}
</nav>
<button className="arena-exit" type="button" onClick={() => void exit()}>{result ? "Leave arena" : "Exit / bot takeover"}</button>
{result && (
<section className="arena-result-panel" role="dialog" aria-modal="true">
<p className="front-kicker">Match complete</p>
<h1>{result.draw ? "Draw" : result.winnerTeam === localActor?.team ? "Victory" : "Defeat"}</h1>
{result.ratingUpdates.map((update) => <p key={`${update.characterId}-${update.bracket}`}>{update.bracket}: {update.before} {update.delta >= 0 ? "+" : ""}{update.delta} → {update.after} / {update.title}</p>)}
{!result.ratingUpdates.length && <p>Practice result — rating unchanged.</p>}
<button type="button" className="front-button front-button--primary" onClick={() => void exit()}>Return to Arena Command</button>
</section>
)}
</main>
);
}
+34 -1
View File
@@ -14,6 +14,7 @@ import {
updateCharacterManastormProgress,
updateCharacterProgression,
updateCharacterSettings,
updateCharacterSpecialization,
updateCombatPresentationSettings,
type StorageLike,
} from "./accountRepository";
@@ -29,6 +30,7 @@ import {
recordManastormFailure,
setManastormLoadout,
} from "../game/manastormProgress";
import { abilitiesForClass } from "../game/abilityCatalog";
class MemoryStorage implements StorageLike {
private values = new Map<string, string>();
@@ -38,6 +40,37 @@ class MemoryStorage implements StorageLike {
}
describe("local profile repository", () => {
it("persists specializations and supports Class Hall keep/reset choices", () => {
const storage = new MemoryStorage();
const talentAbility = abilitiesForClass("stormbringer").find((ability) => ability.talentEntryId !== undefined)!;
const created = createCharacter({
ownerId: "offline-roster",
name: "Tempest",
categoryId: "coa",
raceId: "human",
classId: "stormbringer",
specializationId: "stormbringer:lightning",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
updateCharacterProgression("offline-roster", created.id, {
level: 20,
experience: 0,
talentRanks: { [`coa-entry-${talentAbility.talentEntryId}`]: 1 },
}, storage);
updateCharacterActionBindings("offline-roster", created.id, { "primary:face-top": talentAbility.id }, storage);
const kept = updateCharacterSpecialization("offline-roster", created.id, "stormbringer:wind", false, storage).character!;
expect(kept.specializationId).toBe("stormbringer:wind");
expect(kept.talentRanks).not.toEqual({});
expect(kept.actionBindings["primary:face-top"]).toBe(talentAbility.id);
const reset = updateCharacterSpecialization("offline-roster", created.id, "stormbringer:maelstrom", true, storage).character!;
expect(reset.specializationId).toBe("stormbringer:maelstrom");
expect(reset.talentRanks).toEqual({});
expect(reset.actionBindings["primary:face-top"]).toBeNull();
});
it("creates and verifies a local account without storing its raw password", async () => {
const storage = new MemoryStorage();
const created = await createLocalAccount("CaveHealer", "serpent-pass", storage);
@@ -149,7 +182,7 @@ describe("local profile repository", () => {
}));
expect(loadCombatPresentationSettings("offline-roster", storage))
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(3);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(4);
}
const legacyV3Storage = new MemoryStorage();
+66 -6
View File
@@ -22,6 +22,11 @@ import {
normalizeManastormProgress,
type ManastormProgress,
} from "../game/manastormProgress";
import { abilitiesForClass } from "../game/abilityCatalog";
import {
normalizeSpecializationId,
specializationById,
} from "../game/specializationCatalog";
const DATABASE_KEY = "healer-man.profile-database.v1";
const SESSION_KEY = "healer-man.session.v1";
@@ -51,7 +56,7 @@ interface LocalAccount {
}
interface ProfileDatabase {
version: 3;
version: 4;
accounts: LocalAccount[];
rosters: Record<string, CharacterProfile[]>;
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
@@ -93,7 +98,7 @@ function browserSessionStorage(): StorageLike | null {
}
function emptyDatabase(): ProfileDatabase {
return { version: 3, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
return { version: 4, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
}
function isCharacterProfile(value: unknown): value is CharacterProfile {
@@ -120,6 +125,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
settings?: unknown;
secondaryClassId?: unknown;
actionLoadouts?: unknown;
specializationId?: unknown;
};
const level = typeof legacy.level === "number" && Number.isFinite(legacy.level)
? Math.max(1, Math.min(80, Math.floor(legacy.level)))
@@ -138,6 +144,9 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
.map(([bindingId, abilityId]) => [bindingId, abilityId as string | null]))
: {};
const categoryId = contentCategoryForClass(value.classId);
const specializationId = categoryId === "rom"
? null
: normalizeSpecializationId(value.classId, legacy.specializationId);
const secondaryClassId = categoryId === "rom"
&& isRomClassId(value.classId)
&& typeof legacy.secondaryClassId === "string"
@@ -168,6 +177,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
talentRanks,
actionBindings,
categoryId,
specializationId,
secondaryClassId,
actionLoadouts,
inventory,
@@ -186,7 +196,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
rosters?: unknown;
presentationSettingsByOwner?: unknown;
}) | null;
if (!parsed || ![1, 2, 3].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
if (!parsed || ![1, 2, 3, 4].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
return emptyDatabase();
}
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
@@ -226,8 +236,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
return [ownerId, normalized];
}),
);
const database: ProfileDatabase = { version: 3, accounts, rosters, presentationSettingsByOwner };
if (parsed.version !== 3 || presentationSettingsMigrated) writeDatabase(database, storage);
const database: ProfileDatabase = { version: 4, accounts, rosters, presentationSettingsByOwner };
if (parsed.version !== 4 || presentationSettingsMigrated) writeDatabase(database, storage);
return database;
} catch {
return emptyDatabase();
@@ -421,7 +431,9 @@ export function replaceCharactersForOwner(
}
export function createCharacter(
profile: Omit<CharacterProfile, "id" | "createdAt" | "lastPlayedAt" | "level" | "experience" | "talentRanks" | "actionBindings" | "inventory" | "equipment" | "manastormProgress" | "location">,
profile: Omit<CharacterProfile, "id" | "createdAt" | "lastPlayedAt" | "level" | "experience" | "talentRanks" | "actionBindings" | "inventory" | "equipment" | "manastormProgress" | "location" | "specializationId"> & {
specializationId?: string | null;
},
storage: StorageLike | null = browserLocalStorage(),
): CharacterResult {
if (!storage) return { ok: false, error: "Local storage is unavailable on this device." };
@@ -491,6 +503,54 @@ export function updateCharacterProgression(
return updated;
}
export function updateCharacterSpecialization(
ownerId: string,
characterId: string,
specializationId: string,
resetTalents: boolean,
storage: StorageLike | null = browserLocalStorage(),
): CharacterResult {
if (!storage) return { ok: false, error: "Local storage is unavailable on this device." };
const database = readDatabase(storage);
let result: CharacterResult = { ok: false, error: "That character is no longer available." };
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
if (character.id !== characterId) return character;
if (character.categoryId === "rom") {
result = { ok: false, error: "RoM gear follows the active primary class and activity role." };
return character;
}
const specialization = specializationById(specializationId);
if (!specialization || specialization.classId !== character.classId) {
result = { ok: false, error: "That specialization is not available to this class." };
return character;
}
const talentAbilityIds = resetTalents
? new Set(abilitiesForClass(character.classId)
.filter((ability) => ability.talentEntryId !== undefined || ability.source === "coa-tree")
.map((ability) => ability.id))
: new Set<string>();
const actionBindings = resetTalents
? Object.fromEntries(Object.entries(character.actionBindings)
.map(([bindingId, abilityId]) => [bindingId, abilityId && talentAbilityIds.has(abilityId) ? null : abilityId]))
: character.actionBindings;
const loadoutKey = orderedClassLoadoutKey(character.classId, character.secondaryClassId);
const updated = normalizeCharacterProfile({
...character,
specializationId: specialization.id,
talentRanks: resetTalents ? {} : character.talentRanks,
actionBindings,
actionLoadouts: {
...(character.actionLoadouts ?? {}),
[loadoutKey]: { ...actionBindings },
},
});
result = { ok: true, character: updated };
return updated;
});
if (result.ok) writeDatabase(database, storage);
return result;
}
export function updateCharacterActionBindings(
ownerId: string,
characterId: string,
+76
View File
@@ -0,0 +1,76 @@
import type { PlayerSession } from "./types";
import { onlineApiOrigin, requestOnlineJson } from "./onlineAccountClient";
import type { ArenaLoadout, ArenaQueueRequest, ArenaQueueState, ArenaStageManifest } from "../game/arenaTypes";
export interface ArenaStateResponse {
characterId: string | null;
loadouts: ArenaLoadout[];
ratings: Record<"2v2" | "3v3", { bracket: "2v2" | "3v3"; rating: number; title: string; updatedAt: number | null } | null>;
queue: ArenaQueueState;
stages: ArenaStageManifest[];
}
function token(session: PlayerSession): string {
if (session.kind !== "account" || !session.accessToken) throw new Error("Rated arenas require an online account.");
return session.accessToken;
}
export function fetchArenaState(session: PlayerSession, characterId: string): Promise<ArenaStateResponse> {
return requestOnlineJson(`/api/arena/state?characterId=${encodeURIComponent(characterId)}`, {}, token(session));
}
export function putArenaLoadout(
session: PlayerSession,
characterId: string,
slot: number,
loadout: Omit<ArenaLoadout, "slot" | "characterId" | "level" | "version" | "selected" | "updatedAt"> & { selected?: boolean },
): Promise<ArenaLoadout> {
return requestOnlineJson(`/api/arena/loadouts/${slot}`, {
method: "PUT",
body: JSON.stringify({ ...loadout, characterId }),
}, token(session));
}
export function deleteArenaLoadout(session: PlayerSession, characterId: string, slot: number) {
return requestOnlineJson<{ ok: true }>(`/api/arena/loadouts/${slot}`, {
method: "DELETE",
body: JSON.stringify({ characterId }),
}, token(session));
}
export function enterArenaQueue(session: PlayerSession, request: ArenaQueueRequest): Promise<ArenaQueueState> {
return requestOnlineJson("/api/arena/queue", { method: "POST", body: JSON.stringify(request) }, token(session));
}
export function leaveArenaQueue(session: PlayerSession): Promise<ArenaQueueState> {
return requestOnlineJson("/api/arena/queue", { method: "DELETE" }, token(session));
}
export function acceptArenaReadyCheck(session: PlayerSession): Promise<ArenaQueueState> {
return requestOnlineJson("/api/arena/ready", { method: "POST" }, token(session));
}
interface ArenaSocketTicket { ticket: string; expiresAt: number; path: string }
export async function openArenaSocket(
session: PlayerSession,
onMessage: (message: unknown) => void,
onClose: () => void,
): Promise<WebSocket> {
const response = await requestOnlineJson<ArenaSocketTicket>("/api/arena/socket-ticket", { method: "POST" }, token(session));
const origin = onlineApiOrigin();
const base = origin || window.location.origin;
const url = new URL(response.path, base);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.searchParams.set("ticket", response.ticket);
const socket = new WebSocket(url);
socket.addEventListener("message", (event) => {
try { onMessage(JSON.parse(String(event.data))); } catch { /* Ignore malformed server messages. */ }
});
socket.addEventListener("close", onClose);
await new Promise<void>((resolve, reject) => {
socket.addEventListener("open", () => resolve(), { once: true });
socket.addEventListener("error", () => reject(new Error("Could not connect to the arena server.")), { once: true });
});
return socket;
}
+11 -2
View File
@@ -10,6 +10,7 @@ import {
import { useShellStore } from "./shellStore";
import { registerDungeonShellRuntime } from "./dungeonShellRuntimeBridge";
import { touchCharacter } from "./accountRepository";
import { specializationById } from "../game/specializationCatalog";
export function installDungeonShellRuntime(): void {
registerDungeonShellRuntime({ enterDungeon });
@@ -33,7 +34,16 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
useShellStore.setState({ notice: "That character is no longer available." });
return false;
}
const playerRole = requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
const savedSpecialization = specializationById(activeCharacter.specializationId);
if (activeCharacter.categoryId !== "rom" && !savedSpecialization) {
useShellStore.setState({ phase: "class-hall", notice: "Choose a specialization before entering activities." });
return false;
}
const playerRole = savedSpecialization?.role ?? requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
if (savedSpecialization && requestedRole && requestedRole !== savedSpecialization.role) {
useShellStore.setState({ notice: `${savedSpecialization.displayName} is locked to ${PARTY_ROLE_LABELS[savedSpecialization.role]}.` });
return false;
}
if (!classCanFillPartyRole(activeCharacter.classId, playerRole)) {
useShellStore.setState({
notice: `${activeCharacter.name}'s class cannot enter as ${PARTY_ROLE_LABELS[playerRole]}.`,
@@ -56,4 +66,3 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
});
return true;
}
+11 -1
View File
@@ -30,6 +30,7 @@ import {
type ManastormPartySize,
} from "../game/manastormProgress";
import { activateManastormStage, releaseActiveManastormStage } from "../game/manastormStageLoader";
import { specializationById } from "../game/specializationCatalog";
import {
manastormActiveSpellLoadoutModel,
validateManastormActiveSpellLoadout,
@@ -63,7 +64,16 @@ function enterManastorm(
useShellStore.setState({ notice: "That character is no longer available." });
return false;
}
const playerRole = requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
const savedSpecialization = specializationById(activeCharacter.specializationId);
if (activeCharacter.categoryId !== "rom" && !savedSpecialization) {
useShellStore.setState({ phase: "class-hall", notice: "Choose a specialization before entering activities." });
return false;
}
const playerRole = savedSpecialization?.role ?? requestedRole ?? defaultPartyRoleForClass(activeCharacter.classId);
if (savedSpecialization && requestedRole && requestedRole !== savedSpecialization.role) {
useShellStore.setState({ notice: `${savedSpecialization.displayName} is locked to ${PARTY_ROLE_LABELS[savedSpecialization.role]}.` });
return false;
}
if (!classCanFillPartyRole(activeCharacter.classId, playerRole)) {
useShellStore.setState({
notice: `${activeCharacter.name}'s class cannot enter as ${PARTY_ROLE_LABELS[playerRole]}.`,
+2 -2
View File
@@ -40,7 +40,7 @@ let pendingCharacters: CharacterProfile[] | null = null;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let saveQueue = Promise.resolve();
function apiOrigin(): string {
export function onlineApiOrigin(): string {
const configured = String(import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, "");
if (configured) return configured;
return Capacitor.isNativePlatform() ? NATIVE_API_ORIGIN : "";
@@ -53,7 +53,7 @@ export async function requestOnlineJson<T>(pathname: string, init: RequestInit =
if (token) headers.set("Authorization", `Bearer ${token}`);
let response: Response;
try {
response = await fetch(`${apiOrigin()}${pathname}`, { ...init, headers });
response = await fetch(`${onlineApiOrigin()}${pathname}`, { ...init, headers });
} catch {
throw new OnlineApiError("The online server is unavailable. Check your connection or play offline.", 0);
}
+1
View File
@@ -5,6 +5,7 @@ export interface OnlineGroupCharacter {
readonly id: string;
readonly name: string;
readonly classId: string;
readonly specializationId?: string | null;
readonly categoryId: string;
readonly raceId: string;
readonly gender: "female" | "male";
+1 -1
View File
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
},
});
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(3);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(4);
});
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
+14 -4
View File
@@ -30,6 +30,7 @@ const character = {
name: "Remedy",
raceId: "human" as const,
classId: "priest" as const,
specializationId: "priest:holy",
gender: "female" as const,
appearance: createDefaultAppearance(),
level: 1,
@@ -100,6 +101,15 @@ describe("shell store", () => {
expect(useShellStore.getState().notice).toMatch(/select or create/i);
});
it("routes a legacy WoW character through the mandatory specialization picker", () => {
prepareSelectedCharacter();
useShellStore.setState({ characters: [{ ...character, specializationId: null }] });
expect(useShellStore.getState().openMainMenu()).toBe(true);
expect(useShellStore.getState().phase).toBe("class-hall");
expect(useShellStore.getState().openDungeons()).toBe(false);
expect(useShellStore.getState().notice).toMatch(/choose a specialization/i);
});
it("opens the main menu and dungeon list without resetting gameplay", () => {
prepareSelectedCharacter();
useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 });
@@ -124,11 +134,11 @@ describe("shell store", () => {
useShellStore.getState().openDungeons();
useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 });
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "damage")).toBe(true);
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "healer")).toBe(true);
expect(useShellStore.getState()).toMatchObject({
phase: "game",
activeCharacter: character,
activeDungeonRole: "damage",
activeDungeonRole: "healer",
});
expect(useGameStore.getState()).toMatchObject({
paused: false,
@@ -142,7 +152,7 @@ describe("shell store", () => {
prepareSelectedCharacter();
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "tank")).toBe(false);
expect(useShellStore.getState().phase).toBe("characters");
expect(useShellStore.getState().notice).toMatch(/cannot enter as tank/i);
expect(useShellStore.getState().notice).toMatch(/locked to healer/i);
});
it("rejects an unknown dungeon without resetting or leaving the list", () => {
@@ -234,7 +244,7 @@ describe("shell store", () => {
expect(useShellStore.getState().openManastorms()).toBe(true);
expect(useShellStore.getState().phase).toBe("manastorms");
expect(useShellStore.getState().enterManastorm("tank")).toBe(false);
expect(useShellStore.getState().notice).toMatch(/cannot enter as tank/i);
expect(useShellStore.getState().notice).toMatch(/locked to healer/i);
expect(useShellStore.getState().enterManastorm("healer")).toBe(true);
expect(useShellStore.getState()).toMatchObject({
+74 -3
View File
@@ -14,6 +14,7 @@ import {
updateCharacterManastormProgress,
updateCharacterProgression,
updateCharacterSettings,
updateCharacterSpecialization,
updateCombatPresentationSettings as persistCombatPresentationSettings,
swapRomClasses,
} from "./accountRepository";
@@ -55,6 +56,11 @@ import {
} from "./onlineAccountClient";
import { isGameMasterSession } from "../game/manastormAdminConfig";
import { useGameStore } from "../game/store";
import {
normalizeSpecializationId,
specializationById,
specializationsForClass,
} from "../game/specializationCatalog";
export interface ShellState {
phase: ShellPhase;
@@ -80,8 +86,10 @@ export interface ShellState {
openMainMenu: () => boolean;
openDungeons: () => boolean;
openManastorms: () => boolean;
openArenas: () => boolean;
openGmAdmin: () => boolean;
openClassHall: () => boolean;
changeSelectedSpecialization: (specializationId: string, resetTalents: boolean) => boolean;
chooseRomSecondaryClass: (classId: RomClassId) => boolean;
swapSelectedRomClasses: () => boolean;
returnToMainMenu: () => void;
@@ -110,11 +118,13 @@ export interface ShellState {
function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
const raceId: RaceId = categoryId === "rom" ? "rom-human" : "human";
const classId = categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest";
return {
name: "",
categoryId,
raceId,
classId: categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest",
classId,
specializationId: specializationsForClass(classId)[0]?.id ?? null,
gender: "female",
appearance: createDefaultAppearance(),
rotation: -12,
@@ -202,6 +212,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
if (!availableClasses.some((definition) => definition.id === next.classId)) {
next.classId = availableClasses[0].id;
}
next.specializationId = next.categoryId === "rom"
? null
: normalizeSpecializationId(next.classId, next.specializationId)
?? specializationsForClass(next.classId)[0]?.id
?? null;
next.appearance = clampAppearance(next.raceId, next.gender, next.appearance);
return { draft: next, notice: "" };
}),
@@ -233,12 +248,17 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ notice: "That gender model is not available for the selected race." });
return false;
}
if (draft.categoryId !== "rom" && !normalizeSpecializationId(draft.classId, draft.specializationId)) {
set({ notice: "Choose a specialization before creating this character." });
return false;
}
const result = persistCharacter({
ownerId: session.ownerId,
name: normalizeCharacterName(draft.name),
categoryId: draft.categoryId,
raceId: draft.raceId,
classId: draft.classId,
specializationId: draft.categoryId === "rom" ? null : draft.specializationId,
gender: draft.gender,
appearance: clampAppearance(draft.raceId, draft.gender, draft.appearance),
});
@@ -278,6 +298,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ notice: "That character is no longer available." });
return false;
}
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
if (character.categoryId !== "rom" && !character.specializationId) {
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before continuing." });
return true;
}
set({ phase: "main-menu", activeCharacter: null, notice: "" });
return true;
},
@@ -287,6 +312,11 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
return false;
}
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
if (character.categoryId !== "rom" && !character.specializationId) {
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." });
return false;
}
set({ phase: "dungeons", activeCharacter: null, notice: "" });
return true;
},
@@ -296,9 +326,23 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
return false;
}
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
if (character.categoryId !== "rom" && !character.specializationId) {
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." });
return false;
}
set({ phase: "manastorms", activeCharacter: null, notice: "" });
return true;
},
openArenas: () => {
const { session, selectedCharacterId, characters } = get();
if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
return false;
}
set({ phase: "arenas", activeCharacter: null, notice: "" });
return true;
},
openGmAdmin: () => {
const { session, selectedCharacterId, characters } = get();
if (!isGameMasterSession(session)) {
@@ -315,13 +359,40 @@ export const useShellStore = create<ShellState>((set, get) => ({
openClassHall: () => {
const { session, selectedCharacterId, characters } = get();
const character = characters.find((entry) => entry.id === selectedCharacterId);
if (!session || !character || character.categoryId !== "rom") {
set({ notice: "The Class Hall is available to Runes of Magic characters." });
if (!session || !character) {
set({ notice: "Select a character before opening the Class Hall." });
return false;
}
set({ phase: "class-hall", activeCharacter: null, notice: "" });
return true;
},
changeSelectedSpecialization: (specializationId, resetTalents) => {
const { session, selectedCharacterId, phase } = get();
if (!session || !selectedCharacterId || phase !== "class-hall") {
set({ notice: "Specializations may only be changed inside the Class Hall." });
return false;
}
const definition = specializationById(specializationId);
if (!definition) {
set({ notice: "That specialization is unavailable." });
return false;
}
const result = updateCharacterSpecialization(
session.ownerId,
selectedCharacterId,
definition.id,
resetTalents,
);
if (!result.ok || !result.character) {
set({ notice: result.error ?? "The specialization could not be changed." });
return false;
}
set((state) => ({
characters: state.characters.map((entry) => entry.id === result.character!.id ? result.character! : entry),
notice: `${definition.displayName} selected. ${resetTalents ? "Talents reset." : "Talents kept."}`,
}));
return true;
},
chooseRomSecondaryClass: (classId) => {
const { session, selectedCharacterId } = get();
if (!session || !selectedCharacterId) return false;
+4 -1
View File
@@ -5,7 +5,7 @@ import type { EquipmentAssignments } from "../game/equipment";
import type { GameplaySettings } from "../game/combatStore";
import type { ManastormProgress } from "../game/manastormProgress";
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "gm-admin" | "game";
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "arenas" | "arena-game" | "gm-admin" | "game";
export type SessionKind = "account" | "offline";
export type AccountRole = "gm";
@@ -24,6 +24,8 @@ export interface CharacterProfile {
name: string;
raceId: RaceId;
classId: ClassId;
/** Stable specialization registry ID. Normalized profiles always include it; optional for legacy callers. */
specializationId?: string | null;
/** Always present after repository normalization; optional for v1 callers. */
categoryId?: ContentCategoryId;
/** RoM's inactive class. The active primary remains `classId`. */
@@ -51,6 +53,7 @@ export interface CharacterDraft {
categoryId: ContentCategoryId;
raceId: RaceId;
classId: ClassId;
specializationId: string | null;
gender: GenderId;
appearance: CharacterAppearance;
rotation: number;
+4 -2
View File
@@ -21,19 +21,20 @@ import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
export function CombatBridge() {
const character = useShellStore((state) => state.activeCharacter);
const gameMode = useGameStore((state) => state.gameMode);
const activeRole = useShellStore((state) => state.activeDungeonRole);
const resetRevision = useGameStore((state) => state.resetRevision);
const mountedResetRevision = useRef(resetRevision);
useEffect(() => {
if (!character) return;
useCombatStore.getState().initializeCharacter(character);
useCombatStore.getState().initializeCharacter({ ...character, role: activeRole });
useCombatStore.getState().synchronizeActors();
if (gameMode === "manastorm") {
activateManastormActiveSpellLoadout(
useManastormStore.getState().activeSpellLoadout,
);
}
}, [character?.id, gameMode]);
}, [activeRole, character?.id, gameMode]);
useEffect(() => {
if (mountedResetRevision.current === resetRevision) return;
@@ -65,6 +66,7 @@ export function CombatBridge() {
// also guarantees a fast return to Character Select cannot outrun a
// deferred save and lose the last level or talent point.
shell.saveActiveProgression(state.level, state.xp, state.talentRanks);
if (state.level !== previousState.level) useCombatStore.getState().refreshResolvedGear();
}
if (state.actionBindings !== previousState.actionBindings) {
+11 -3
View File
@@ -11,7 +11,8 @@ import type {
import { useShellStore } from "../app/shellStore";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
import { resolvedEquipmentItems } from "./heirloomEquipment";
import { getMobRuntimeTransform } from "./mobRuntimeRegistry";
import {
configureOnlineSessionRuntime,
@@ -266,10 +267,17 @@ export function OnlineSessionBridge() {
selectedTargetId: combat.selectedTargetId,
activeCast: combat.activeCast,
animationEvent: combat.playerAnimationEvent,
equipment: equipmentItemsForOwner(
equipment: resolvedEquipmentItems(
combat.equipment,
combat.inventory,
PLAYER_EQUIPMENT_OWNER_ID,
{
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: combat.classId,
specializationId: combat.specializationId,
role: combat.gearRole,
level: combat.level,
},
),
},
...(publishWorld ? { world: sharedWorld() } : {}),
+5 -1
View File
@@ -40,7 +40,10 @@ export function PartyBridge() {
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
useEffect(() => {
usePartyStore.getState().syncLevel(level);
const party = usePartyStore.getState();
const combat = useCombatStore.getState();
party.syncLevel(level);
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
}, [level]);
useEffect(() => {
@@ -48,6 +51,7 @@ export function PartyBridge() {
mountedResetRevision.current = resetRevision;
const combat = useCombatStore.getState();
usePartyStore.getState().resetCombat(combat.level, combat.health);
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
resetPartyRuntimeClock();
}, [resetRevision]);
+68
View File
@@ -0,0 +1,68 @@
import type { CharacterProfile } from "../app/types";
import { ArenaSimulation } from "../../server/arena-simulation.mjs";
import type { ArenaAuthoritativeSnapshot, ArenaCombatIntent, ArenaResult, ArenaRole, ArenaStageManifest } from "./arenaTypes";
interface PracticeOptions {
character: CharacterProfile;
role: ArenaRole;
bracket: "2v2" | "3v3";
ruleset: "wow" | "coa" | "rom";
stage: ArenaStageManifest;
difficulty: number;
onSnapshot: (snapshot: ArenaAuthoritativeSnapshot) => void;
onResult: (result: ArenaResult) => void;
}
function teamRoles(teamSize: number, playerRole?: ArenaRole): ArenaRole[] {
const roles: ArenaRole[] = playerRole ? [playerRole] : [];
if (!roles.includes("healer")) roles.push("healer");
while (roles.length < teamSize) roles.push("damage");
return roles;
}
export function createArenaPractice(options: PracticeOptions) {
const id = `practice-${Date.now().toString(36)}`;
const teamSize = options.bracket === "2v2" ? 2 : 3;
const participants = [
...teamRoles(teamSize, options.role).map((role, index) => ({
actorId: index === 0 ? "practice-player" : `practice-ally-${index}`,
accountId: null,
characterId: index === 0 ? options.character.id : null,
name: index === 0 ? options.character.name : role === "healer" ? "Training Medic" : `Training Ally ${index}`,
team: 0 as const,
role,
isBot: index !== 0,
rating: options.difficulty,
})),
...teamRoles(teamSize).map((role, index) => ({
actorId: `practice-enemy-${index}`,
accountId: null,
characterId: null,
name: role === "healer" ? "Opponent Medic" : `Opponent ${index + 1}`,
team: 1 as const,
role,
isBot: true,
rating: options.difficulty,
})),
];
const simulation = new ArenaSimulation({
id,
stage: options.stage,
bracket: options.bracket,
mode: "practice",
ruleset: options.ruleset,
participants,
onSnapshot: (snapshot) => options.onSnapshot({ ...snapshot, controlledActorId: "practice-player" }),
onResult: (result) => options.onResult({ ...result, ratingUpdates: [] }),
});
simulation.start();
options.onSnapshot({ ...simulation.snapshot(), controlledActorId: "practice-player" });
return {
intent(intent: ArenaCombatIntent) {
return simulation.receiveIntent("practice-player", intent);
},
stop() {
simulation.stop();
},
};
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
arenaAiProfile,
arenaDampening,
arenaDrDuration,
arenaRatingBand,
calculateArenaRating,
clampArenaRating,
} from "./arenaRules";
describe("arena rating and difficulty", () => {
it("clamps ratings and applies the requested Elo bands", () => {
expect(clampArenaRating(-5)).toBe(0);
expect(clampArenaRating(2600)).toBe(2500);
expect(arenaRatingBand(0)).toMatchObject({ title: "Recruit", k: 64 });
expect(arenaRatingBand(2400)).toMatchObject({ title: "Gladiator", k: 20 });
expect(calculateArenaRating(0, 0, 1)).toMatchObject({ delta: 32, after: 32 });
expect(calculateArenaRating(0, 0, 0)).toMatchObject({ delta: 0, after: 0 });
expect(calculateArenaRating(1250, 1250, 0.5)).toMatchObject({ delta: 0, after: 1250 });
});
it("continuously improves bots without removing the error floor", () => {
const recruit = arenaAiProfile(0);
const rival = arenaAiProfile(1600);
const gladiator = arenaAiProfile(2500);
expect(rival.reactionMinSeconds).toBeLessThan(recruit.reactionMinSeconds);
expect(gladiator.healerInterruptChance).toBe(0.85);
expect(gladiator.minimumErrorChance).toBe(0.08);
});
it("starts dampening at ten percent and reaches zero healing at nine minutes", () => {
expect(arenaDampening(0)).toBeCloseTo(0.1);
expect(arenaDampening(30_000)).toBeCloseTo(0.15);
expect(arenaDampening(540_000)).toBe(1);
});
it("uses full, half, quarter, immune DR and resets after fifteen seconds", () => {
expect(arenaDrDuration(8_000, 0, 0).durationMs).toBe(8_000);
expect(arenaDrDuration(8_000, 1, 0).durationMs).toBe(4_000);
expect(arenaDrDuration(8_000, 2, 0).durationMs).toBe(2_000);
expect(arenaDrDuration(8_000, 3, 0).durationMs).toBe(0);
expect(arenaDrDuration(8_000, 3, 15_000).durationMs).toBe(8_000);
});
});
+80
View File
@@ -0,0 +1,80 @@
export type ArenaBracket = "2v2" | "3v3";
export type ArenaMode = "practice" | "rated-ai" | "ranked-pvp";
export type ArenaRuleset = "wow" | "coa" | "rom";
export type ArenaRole = "healer" | "damage";
export interface ArenaAiProfile {
readonly rating: number;
readonly title: string;
readonly reactionMinSeconds: number;
readonly reactionMaxSeconds: number;
readonly healerInterruptChance: number;
readonly coordinatedCcChance: number;
readonly correctDefensiveChance: number;
readonly minimumErrorChance: number;
}
export const ARENA_BRACKETS: Readonly<Record<ArenaBracket, { readonly size: 2 | 3; readonly healers: 1; readonly damage: 1 | 2 }>> = Object.freeze({
"2v2": Object.freeze({ size: 2, healers: 1, damage: 1 }),
"3v3": Object.freeze({ size: 3, healers: 1, damage: 2 }),
});
export const ARENA_RATING_BANDS = Object.freeze([
{ min: 0, max: 499, title: "Recruit", k: 64, reaction: [1.4, 2.4], healerInterrupt: 0, coordinatedCc: 0.05, defensive: 0.35, error: 0.35 },
{ min: 500, max: 999, title: "Combatant", k: 48, reaction: [1, 1.8], healerInterrupt: 0.2, coordinatedCc: 0.2, defensive: 0.5, error: 0.28 },
{ min: 1000, max: 1399, title: "Challenger", k: 40, reaction: [0.75, 1.3], healerInterrupt: 0.4, coordinatedCc: 0.4, defensive: 0.65, error: 0.2 },
{ min: 1400, max: 1799, title: "Rival", k: 32, reaction: [0.55, 1], healerInterrupt: 0.6, coordinatedCc: 0.6, defensive: 0.75, error: 0.15 },
{ min: 1800, max: 2099, title: "Duelist", k: 28, reaction: [0.4, 0.8], healerInterrupt: 0.72, coordinatedCc: 0.72, defensive: 0.82, error: 0.12 },
{ min: 2100, max: 2399, title: "Elite", k: 24, reaction: [0.3, 0.65], healerInterrupt: 0.8, coordinatedCc: 0.8, defensive: 0.88, error: 0.1 },
{ min: 2400, max: 2500, title: "Gladiator", k: 20, reaction: [0.25, 0.55], healerInterrupt: 0.85, coordinatedCc: 0.85, defensive: 0.9, error: 0.08 },
] as const);
export function clampArenaRating(value: number): number {
return Math.max(0, Math.min(2500, Math.round(Number(value) || 0)));
}
export function arenaRatingBand(rating: number): typeof ARENA_RATING_BANDS[number] {
const normalized = clampArenaRating(rating);
return ARENA_RATING_BANDS.find((band) => normalized >= band.min && normalized <= band.max) ?? ARENA_RATING_BANDS[0];
}
function lerp(left: number, right: number, amount: number): number {
return left + (right - left) * Math.max(0, Math.min(1, amount));
}
export function arenaAiProfile(rating: number): ArenaAiProfile {
const normalized = clampArenaRating(rating);
const index = ARENA_RATING_BANDS.findIndex((band) => normalized >= band.min && normalized <= band.max);
const current = ARENA_RATING_BANDS[Math.max(0, index)];
const next = ARENA_RATING_BANDS[Math.min(ARENA_RATING_BANDS.length - 1, Math.max(0, index) + 1)];
const progress = current === next ? 0 : (normalized - current.min) / Math.max(1, current.max - current.min + 1);
return {
rating: normalized,
title: current.title,
reactionMinSeconds: lerp(current.reaction[0], next.reaction[0], progress),
reactionMaxSeconds: lerp(current.reaction[1], next.reaction[1], progress),
healerInterruptChance: lerp(current.healerInterrupt, next.healerInterrupt, progress),
coordinatedCcChance: lerp(current.coordinatedCc, next.coordinatedCc, progress),
correctDefensiveChance: lerp(current.defensive, next.defensive, progress),
minimumErrorChance: lerp(current.error, next.error, progress),
};
}
export function calculateArenaRating(rating: number, opponentRating: number, score: 0 | 0.5 | 1) {
const before = clampArenaRating(rating);
if (score === 0.5) return { before, expected: 0.5, delta: 0, after: before };
const expected = 1 / (1 + 10 ** ((clampArenaRating(opponentRating) - before) / 400));
const rawDelta = Math.round(arenaRatingBand(before).k * (score - expected));
const after = clampArenaRating(before + rawDelta);
return { before, expected, delta: after - before, after };
}
export function arenaDampening(elapsedMs: number): number {
return Math.min(1, 0.1 + Math.floor(Math.max(0, elapsedMs) / 30_000) * 0.05);
}
export function arenaDrDuration(baseDurationMs: number, priorApplications: number, elapsedSinceEndMs = Infinity) {
const applications = elapsedSinceEndMs >= 15_000 ? 0 : Math.max(0, Math.trunc(priorApplications));
const multiplier = [1, 0.5, 0.25, 0][Math.min(3, applications)];
return { durationMs: Math.round(Math.max(0, baseDurationMs) * multiplier), applications: Math.min(3, applications + 1), multiplier };
}
+25
View File
@@ -0,0 +1,25 @@
import catalog from "../../shared/arena-stages.json";
import type { ArenaStageManifest } from "./arenaTypes";
function normalizeStage(stage: Omit<ArenaStageManifest, "assets">): ArenaStageManifest {
return {
...stage,
assets: [
{ id: "visual", url: `${stage.assetBaseUrl}/visual.glb` },
{ id: "collision", url: `${stage.assetBaseUrl}/collision.glb` },
{ id: "navigation", url: `${stage.assetBaseUrl}/navigation.glb` },
],
};
}
export const ARENA_STAGES = Object.freeze(
catalog.stages.map((stage) => normalizeStage(stage as unknown as Omit<ArenaStageManifest, "assets">)),
);
export const VALIDATED_ARENA_STAGES = Object.freeze(
ARENA_STAGES.filter((stage) => Object.values(stage.validation).slice(1, 6).every(Boolean)),
);
export function arenaStageById(stageId: string) {
return ARENA_STAGES.find((stage) => stage.id === stageId) ?? null;
}
+245
View File
@@ -0,0 +1,245 @@
import { create } from "zustand";
import {
acceptArenaReadyCheck,
deleteArenaLoadout as deleteRemoteLoadout,
enterArenaQueue,
fetchArenaState,
leaveArenaQueue,
openArenaSocket,
putArenaLoadout as putRemoteLoadout,
type ArenaStateResponse,
} from "../app/arenaClient";
import type { CharacterProfile, PlayerSession } from "../app/types";
import { useShellStore } from "../app/shellStore";
import { arenaAiProfile } from "./arenaRules";
import { ARENA_STAGES } from "./arenaStages";
import { createArenaPractice } from "./arenaPractice";
import type { ArenaAuthoritativeSnapshot, ArenaCombatIntent, ArenaLoadout, ArenaQueueState, ArenaResult } from "./arenaTypes";
import { romGearProfile, specializationById, specializationsForClass } from "./specializationCatalog";
type ArenaModeSelection = "offline-practice" | "rated-ai" | "ranked-pvp";
interface ArenaStoreState {
mode: ArenaModeSelection;
bracket: "2v2" | "3v3";
ruleset: "wow" | "coa" | "rom";
stageId: string;
botDifficulty: number;
selectedLoadoutSlot: number;
remote: ArenaStateResponse | null;
queue: ArenaQueueState;
snapshot: ArenaAuthoritativeSnapshot | null;
result: ArenaResult | null;
loading: boolean;
error: string;
socket: WebSocket | null;
practice: ReturnType<typeof createArenaPractice> | null;
sequence: number;
configure: (patch: Partial<Pick<ArenaStoreState, "mode" | "bracket" | "ruleset" | "stageId" | "botDifficulty" | "selectedLoadoutSlot">>) => void;
load: (session: PlayerSession | null, character: CharacterProfile) => Promise<void>;
saveLoadout: (session: PlayerSession, character: CharacterProfile, input: { slot: number; name: string; specializationId: string }) => Promise<boolean>;
deleteLoadout: (session: PlayerSession, characterId: string, slot: number) => Promise<boolean>;
queueMatch: (session: PlayerSession | null, character: CharacterProfile) => Promise<boolean>;
ready: (session: PlayerSession) => Promise<void>;
leave: (session: PlayerSession | null) => Promise<void>;
intent: (intent: Omit<ArenaCombatIntent, "sequence">) => void;
resetResult: () => void;
}
function messageFor(error: unknown) {
return error instanceof Error ? error.message : "The arena request could not be completed.";
}
function rulesetFor(character: CharacterProfile): "wow" | "coa" | "rom" {
return character.categoryId === "coa" || character.categoryId === "rom" ? character.categoryId : "wow";
}
function defaultSpec(character: CharacterProfile, preferredRole: "healer" | "damage" = "healer") {
if (character.categoryId === "rom") return romGearProfile(character.classId, preferredRole);
const selected = specializationById(character.specializationId);
if (selected && selected.role !== "tank") return selected;
return specializationsForClass(character.classId).find((spec) => spec.role === preferredRole)
?? specializationsForClass(character.classId).find((spec) => spec.role !== "tank")
?? null;
}
function handleSocketMessage(raw: unknown) {
const message = raw as { type?: string; snapshot?: ArenaAuthoritativeSnapshot; result?: ArenaResult; deadline?: number; matchId?: string; message?: string };
if (message.type === "snapshot" && message.snapshot) useArenaStore.setState({ snapshot: message.snapshot });
if (message.type === "ready-check") useArenaStore.setState((current) => ({ queue: { ...current.queue, status: "ready-check", readyDeadline: message.deadline, matchId: message.matchId } }));
if (message.type === "match-start" && message.snapshot) {
useArenaStore.setState({ snapshot: message.snapshot, queue: { status: "matched", matchId: message.matchId }, loading: false });
useShellStore.setState({ phase: "arena-game" });
}
if (message.type === "result" && message.result) useArenaStore.setState({ result: message.result, loading: false });
if (message.type === "queue-cancelled") useArenaStore.setState({ queue: { status: "queued" }, error: message.message ?? "Ready check cancelled." });
}
async function connectArenaSocket(session: PlayerSession) {
const existing = useArenaStore.getState().socket;
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) return existing;
const socket = await openArenaSocket(session, handleSocketMessage, () => {
const current = useArenaStore.getState();
const error = current.queue.status === "matched"
? "Arena connection closed; your character is now controlled by a bot."
: current.queue.status !== "idle" ? "Live arena updates disconnected. Reopen Arenas to reconnect." : current.error;
useArenaStore.setState({ socket: null, error });
});
useArenaStore.setState({ socket });
return socket;
}
export const useArenaStore = create<ArenaStoreState>((set, get) => ({
mode: "offline-practice",
bracket: "2v2",
ruleset: "wow",
stageId: "warnorken-arena",
botDifficulty: 1000,
selectedLoadoutSlot: 1,
remote: null,
queue: { status: "idle" },
snapshot: null,
result: null,
loading: false,
error: "",
socket: null,
practice: null,
sequence: 0,
configure: (patch) => set(patch),
load: async (session, character) => {
get().practice?.stop();
get().socket?.close();
const ruleset = rulesetFor(character);
set({ ruleset, loading: Boolean(session?.accessToken), error: "", snapshot: null, result: null, practice: null, socket: null });
if (!session?.accessToken) {
set({ remote: null, queue: { status: "idle" }, loading: false });
return;
}
try {
const remote = await fetchArenaState(session, character.id);
set({ remote, queue: remote.queue, loading: false });
await connectArenaSocket(session);
} catch (error) {
set({ loading: false, error: messageFor(error) });
}
},
saveLoadout: async (session, character, input) => {
const spec = character.categoryId === "rom"
? (["healer", "damage"] as const).map((role) => romGearProfile(character.classId, role)).find((entry) => entry.id === input.specializationId)
: specializationById(input.specializationId);
if (!spec || spec.classId !== character.classId || spec.role === "tank") {
set({ error: "Rated loadouts require a healer or damage specialization from this character's class." });
return false;
}
set({ loading: true, error: "" });
try {
await putRemoteLoadout(session, character.id, input.slot, {
name: input.name,
classId: character.classId,
specializationId: spec.id,
role: spec.role,
ruleset: rulesetFor(character),
talents: character.talentRanks,
actions: Object.fromEntries(Object.entries(character.actionBindings).filter((entry): entry is [string, string] => typeof entry[1] === "string")),
});
const remote = await fetchArenaState(session, character.id);
set({ remote, selectedLoadoutSlot: input.slot, loading: false });
return true;
} catch (error) {
set({ loading: false, error: messageFor(error) });
return false;
}
},
deleteLoadout: async (session, characterId, slot) => {
set({ loading: true, error: "" });
try {
await deleteRemoteLoadout(session, characterId, slot);
const remote = await fetchArenaState(session, characterId);
set({ remote, selectedLoadoutSlot: remote.loadouts[0]?.slot ?? 1, loading: false });
return true;
} catch (error) {
set({ loading: false, error: messageFor(error) });
return false;
}
},
queueMatch: async (session, character) => {
const state = get();
const validatedStages = ARENA_STAGES.filter((entry) => entry.validation.status === "validated");
const stage = state.stageId === "random"
? validatedStages[Math.floor(Math.random() * validatedStages.length)]
: ARENA_STAGES.find((entry) => entry.id === state.stageId) ?? ARENA_STAGES[0];
const fallback = defaultSpec(character);
if (!stage || !fallback) {
set({ error: "This character has no arena-compatible specialization." });
return false;
}
if (state.mode === "offline-practice") {
const practice = createArenaPractice({
character, role: fallback.role as "healer" | "damage", bracket: state.bracket,
ruleset: state.ruleset, stage, difficulty: state.botDifficulty,
onSnapshot: (snapshot) => set({ snapshot }),
onResult: (result) => set({ result }),
});
set({ practice, error: "", result: null, queue: { status: "matched", matchId: practice ? `practice-${Date.now()}` : undefined } });
useShellStore.setState({ phase: "arena-game" });
return true;
}
if (!session?.accessToken) {
set({ error: "Rated arenas require an online account. Offline Practice remains available." });
return false;
}
let loadout = state.remote?.loadouts.find((entry) => entry.slot === state.selectedLoadoutSlot);
if (!loadout) {
const saved = await get().saveLoadout(session, character, { slot: state.selectedLoadoutSlot, name: `${fallback.displayName} PvP`, specializationId: fallback.id });
if (!saved) return false;
loadout = get().remote?.loadouts.find((entry) => entry.slot === state.selectedLoadoutSlot);
}
if (!loadout) return false;
set({ loading: true, error: "", result: null });
try {
const socket = await connectArenaSocket(session);
const queue = await enterArenaQueue(session, {
characterId: character.id, loadoutSlot: loadout.slot, bracket: state.bracket,
mode: state.mode, ruleset: state.ruleset,
botDifficulty: state.botDifficulty,
});
set({ socket, queue, loading: false });
return true;
} catch (error) {
set({ loading: false, error: messageFor(error) });
return false;
}
},
ready: async (session) => {
try { set({ queue: await acceptArenaReadyCheck(session), error: "" }); }
catch (error) { set({ error: messageFor(error) }); }
},
leave: async (session) => {
get().practice?.stop();
get().socket?.close();
if (session?.accessToken && get().queue.status !== "idle") await leaveArenaQueue(session).catch(() => undefined);
set({ queue: { status: "idle" }, snapshot: null, result: null, practice: null, socket: null, error: "" });
},
intent: (intent) => {
const sequence = get().sequence + 1;
const message = { ...intent, sequence } as ArenaCombatIntent;
set({ sequence });
get().practice?.intent(message);
const socket = get().socket;
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
},
resetResult: () => set({ result: null }),
}));
export function currentArenaAiTitle() {
return arenaAiProfile(useArenaStore.getState().botDifficulty).title;
}
+164
View File
@@ -0,0 +1,164 @@
import type { ArenaBracket, ArenaMode, ArenaRuleset } from "./arenaRules";
export type Vec3Tuple = readonly [number, number, number];
export type ArenaRole = "healer" | "damage";
export type ArenaPhase = "queue" | "ready-check" | "preparation" | "active" | "finished" | "void";
export interface ArenaStageAsset {
id: "visual" | "collision" | "navigation";
url: string;
checksum?: string;
byteLength?: number;
}
export interface ArenaStageValidation {
status: "source-ready" | "repair-required" | "validated";
traversal: boolean;
collision: boolean;
spawnSafety: boolean;
lineOfSight: boolean;
performance: boolean;
notes: string[];
}
export interface ArenaLineOfSightObject {
id: string;
shape: "box" | "cylinder";
position: Vec3Tuple;
size: Vec3Tuple;
dynamicObjectId?: string;
}
export interface ArenaDynamicObjectDefinition {
id: string;
kind: "pipe-knockback" | "waterfall" | "starting-elevators" | "fire-cycle" | "moving-pillars";
initialDelayMs?: number;
initialDelayRangeMs?: readonly [number, number];
runtimeStartMs?: number;
warningMs: number;
activeMs: number;
cycleMs: number;
}
export interface ArenaStageManifest {
id: string;
name: string;
sourceMap: string;
mapId: number;
assetBaseUrl: string;
assets: readonly ArenaStageAsset[];
bounds: { min: Vec3Tuple; max: Vec3Tuple };
teamSpawns: readonly [Vec3Tuple, Vec3Tuple];
preparationGates: readonly [Vec3Tuple, Vec3Tuple];
spectatorPositions: readonly [Vec3Tuple, Vec3Tuple];
shadowSight: readonly [Vec3Tuple, Vec3Tuple];
lineOfSight: readonly ArenaLineOfSightObject[];
dynamicObjects: readonly ArenaDynamicObjectDefinition[];
validation: ArenaStageValidation;
}
export interface ArenaLoadout {
slot: number;
name: string;
characterId: string;
classId: string;
specializationId: string;
role: ArenaRole;
ruleset: ArenaRuleset;
level: 80;
talents: Record<string, number>;
actions: Record<string, string>;
version: number;
selected: boolean;
updatedAt: number;
}
export interface ArenaQueueRequest {
characterId: string;
loadoutSlot: number;
bracket: ArenaBracket;
mode: ArenaMode;
ruleset: ArenaRuleset;
botDifficulty?: number;
groupMembers?: readonly { accountId: string; characterId: string; loadoutSlot: number }[];
}
export interface ArenaQueueState {
status: "idle" | "queued" | "ready-check" | "matched";
ticketId?: string;
joinedAt?: number;
searchRange?: number;
readyDeadline?: number;
matchId?: string;
lockedLoadout?: ArenaLoadout;
}
export interface ArenaCombatIntent {
sequence: number;
type: "movement" | "facing" | "target" | "ability";
movement?: { x: number; z: number };
facing?: number;
targetActorId?: string;
abilityId?: string;
clientTick?: number;
}
export interface ArenaActorSnapshot {
id: string;
name: string;
team: 0 | 1;
role: ArenaRole;
isBot: boolean;
disconnected: boolean;
alive: boolean;
position: Vec3Tuple;
facing: number;
health: number;
maxHealth: number;
resource: number;
maxResource: number;
targetActorId: string | null;
cooldowns: Record<string, number>;
effects: readonly { id: string; remainingMs: number; stacks?: number }[];
}
export interface ArenaDynamicObjectSnapshot {
id: string;
state: "closed" | "warning" | "active" | "open" | "raised" | "moving" | "lowered";
progress: number;
collisionEnabled: boolean;
}
export interface ArenaAuthoritativeSnapshot {
matchId: string;
tick: number;
serverTime: number;
phase: ArenaPhase;
phaseEndsAt: number | null;
stageId: string;
bracket: ArenaBracket;
mode: ArenaMode;
ruleset: ArenaRuleset;
dampening: number;
actors: readonly ArenaActorSnapshot[];
dynamicObjects: readonly ArenaDynamicObjectSnapshot[];
acknowledgedSequence?: number;
controlledActorId?: string;
}
export interface ArenaRatingUpdate {
characterId: string;
bracket: ArenaBracket;
before: number;
delta: number;
after: number;
title: string;
}
export interface ArenaResult {
matchId: string;
winnerTeam: 0 | 1 | null;
draw: boolean;
reason: "elimination" | "simultaneous-elimination" | "void";
ratingUpdates: readonly ArenaRatingUpdate[];
}
+78 -9
View File
@@ -131,7 +131,6 @@ import { rollBossLoot } from "./lootCatalog";
import { normalizeInventory, type InventoryItem } from "./lootTypes";
import {
PLAYER_EQUIPMENT_OWNER_ID,
equipmentStats,
activeEquipmentStatsForClass,
equipInventoryItem,
normalizeEquipment,
@@ -139,6 +138,12 @@ import {
type EquipmentAssignments,
type EquipmentSlot,
} from "./equipment";
import {
resolvedEquipmentStats,
type HeirloomContext,
} from "./heirloomEquipment";
import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles";
import { specializationById } from "./specializationCatalog";
import {
EMPTY_ITEM_STATS,
itemStatSummary,
@@ -491,6 +496,8 @@ export interface ActorHealingRequest {
export interface CharacterCombatSeed {
readonly classId: ClassId;
readonly specializationId?: string | null;
readonly role?: PartyRole;
readonly secondaryClassId?: ClassId | null;
readonly raceId?: RaceId;
readonly level?: number;
@@ -593,6 +600,8 @@ export interface PendingGroundTarget {
export interface CombatState {
classId: ClassId;
specializationId: string | null;
gearRole: PartyRole;
secondaryClassId: ClassId | null;
raceId: RaceId;
level: number;
@@ -645,6 +654,7 @@ export interface CombatState {
settings: GameplaySettings;
playerPosition: CombatPosition;
initializeCharacter: (profile: CharacterCombatSeed) => void;
refreshResolvedGear: () => void;
registerMob: (id: string, registration: MobRegistration) => void;
removeMob: (id: string) => void;
lootMob: (id: string, now?: number) => boolean;
@@ -3117,6 +3127,8 @@ function initialState() {
const initialPools = initialResourcePools(classId, abilities, maxResource);
return {
classId,
specializationId: null,
gearRole: "healer" as PartyRole,
secondaryClassId: null,
raceId,
level,
@@ -3175,6 +3187,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
initializeCharacter: (profile) => {
resetCombatPresentationSession();
const classId = profile.classId;
const specialization = specializationById(profile.specializationId);
const specializationId = specialization?.classId === classId ? specialization.id : null;
const gearRole = specialization?.role ?? profile.role ?? defaultPartyRoleForClass(classId);
const secondaryClassId = profile.secondaryClassId ?? null;
const raceId = profile.raceId ?? "human";
const progression = normalizeProgression(profile.level ?? 1, profile.xp ?? profile.experience ?? 0);
@@ -3184,7 +3199,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const abilities = abilitiesForCharacter(classId, secondaryClassId);
const inventory = normalizeInventory(profile.inventory);
const equipment = normalizeEquipment(profile.equipment, inventory);
const gearStats = activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId);
const gearStats = specializationId !== null || classId.startsWith("rom-") ? resolvedEquipmentStats(equipment, inventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId,
specializationId,
role: gearRole,
level: progression.level,
}) : activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId);
const maxHealth = talentAdjustedMaxHealth(
classId,
progression.level,
@@ -3231,6 +3253,44 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}));
},
refreshResolvedGear: () => set((state) => {
const context: HeirloomContext = {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
};
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-")
? resolvedEquipmentStats(state.equipment, state.inventory, context)
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = talentAdjustedMaxHealth(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
);
const maxResource = talentAdjustedMaxResource(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
);
if (gearStats === state.gearStats && maxHealth === state.maxHealth && maxResource === state.maxResource) return state;
return {
gearStats,
maxHealth,
maxResource,
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : maxResource, 0, maxResource),
};
}),
registerMob: (id, registration) => set((state) => {
if (!id) return state;
const existing = state.mobs[id];
@@ -3471,12 +3531,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID
? "You"
: usePartyStore.getState().members.find((member) => member.id === ownerId)?.name ?? "Companion";
const gearStats = activeEquipmentStatsForClass(
change.assignments,
state.inventory,
PLAYER_EQUIPMENT_OWNER_ID,
state.classId,
);
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, state.inventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = talentAdjustedMaxHealth(
state.classId,
state.level,
@@ -3518,7 +3580,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const state = get();
const next = unequipInventorySlot(state.equipment, ownerId, slot);
if (next === state.equipment) return false;
const gearStats = activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(next, state.inventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = talentAdjustedMaxHealth(
state.classId,
state.level,
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more