forked from phenom/healer-man
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b57d2b2839 | ||
|
|
f49e54f35f |
No files matched your search
@@ -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.
|
||||
@@ -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": []
|
||||
}
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
Binary file not shown.
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.
Binary file not shown.
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.
Binary file not shown.
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
@@ -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);
|
||||
}
|
||||
@@ -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`);
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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."] }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]}.`,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,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() } : {}),
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user