Release Healer Man 0.1.16
This commit is contained in:
1 parent
f49e54f35f
commit
b57d2b2839
89 files changed
+6055
-17
No files matched your search
@@ -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",
|
"name": "healer-man",
|
||||||
"version": "0.1.15",
|
"version": "0.1.16",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.15",
|
"version": "0.1.16",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
|
|||||||
+4
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.15",
|
"version": "0.1.16",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
@@ -63,6 +63,8 @@
|
|||||||
"dungeon:navmesh": "node scripts/dungeon-pipeline/cli.mjs navmesh",
|
"dungeon:navmesh": "node scripts/dungeon-pipeline/cli.mjs navmesh",
|
||||||
"dungeon:navmesh:conversion": "node scripts/dungeon-pipeline/cli.mjs navmesh-conversion",
|
"dungeon:navmesh:conversion": "node scripts/dungeon-pipeline/cli.mjs navmesh-conversion",
|
||||||
"dungeon:validate": "node scripts/dungeon-pipeline/cli.mjs validate",
|
"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",
|
"dungeon:enable": "node scripts/dungeon-pipeline/cli.mjs enable",
|
||||||
"runewaker:forsaken": "node \"%INIT_CWD%\\scripts\\runewaker-pipeline\\build-forsaken-abbey.mjs\"",
|
"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",
|
"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",
|
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
|
||||||
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
|
"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",
|
"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": "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",
|
"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",
|
"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.15",
|
"versionName": "0.1.16",
|
||||||
"versionCode": 1015
|
"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 { threeToSoloNavMesh, threeToTiledNavMesh, NavMeshHelper } from "@recast-navigation/three";
|
||||||
import { Vector3 } from "three";
|
import { Vector3 } from "three";
|
||||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
|
import { MeshoptDecoder } from "meshoptimizer";
|
||||||
import {
|
import {
|
||||||
exists,
|
exists,
|
||||||
fixturesRoot,
|
fixturesRoot,
|
||||||
@@ -39,9 +40,10 @@ globalThis.ProgressEvent ??= class ProgressEvent {
|
|||||||
globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} });
|
globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} });
|
||||||
|
|
||||||
async function loadScene(file) {
|
async function loadScene(file) {
|
||||||
|
await MeshoptDecoder.ready;
|
||||||
const bytes = await readFile(file);
|
const bytes = await readFile(file);
|
||||||
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
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) {
|
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,
|
normalizeManastormAdminConfig,
|
||||||
stampManastormReviewMetadata,
|
stampManastormReviewMetadata,
|
||||||
} from "./manastorm-config.mjs";
|
} from "./manastorm-config.mjs";
|
||||||
|
import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena-rules.mjs";
|
||||||
|
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
|
||||||
|
|
||||||
const scrypt = promisify(scryptCallback);
|
const scrypt = promisify(scryptCallback);
|
||||||
|
|
||||||
@@ -115,7 +117,64 @@ export function openGameDatabase(options = {}) {
|
|||||||
) STRICT;
|
) STRICT;
|
||||||
CREATE INDEX IF NOT EXISTS online_group_invites_invitee_idx
|
CREATE INDEX IF NOT EXISTS online_group_invites_invitee_idx
|
||||||
ON online_group_invites(invitee_account_id, status, expires_at);
|
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(`
|
database.prepare(`
|
||||||
INSERT OR IGNORE INTO manastorm_admin_config (
|
INSERT OR IGNORE INTO manastorm_admin_config (
|
||||||
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
|
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 = ?
|
WHERE id = 1 AND revision = ?
|
||||||
RETURNING revision, updated_at
|
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) {
|
function issueSession(accountId) {
|
||||||
@@ -563,6 +639,260 @@ export function openGameDatabase(options = {}) {
|
|||||||
return onlineGroupState(accountId);
|
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 {
|
return {
|
||||||
databasePath,
|
databasePath,
|
||||||
register,
|
register,
|
||||||
@@ -582,6 +912,15 @@ export function openGameDatabase(options = {}) {
|
|||||||
updateOnlineGroupMember,
|
updateOnlineGroupMember,
|
||||||
startOnlineGroupActivity,
|
startOnlineGroupActivity,
|
||||||
clearOnlineGroupActivity,
|
clearOnlineGroupActivity,
|
||||||
|
getArenaState,
|
||||||
|
putArenaLoadout,
|
||||||
|
deleteArenaLoadout,
|
||||||
|
arenaLoadoutForQueue,
|
||||||
|
selectedArenaLoadoutForQueue,
|
||||||
|
getArenaRating,
|
||||||
|
createArenaMatch,
|
||||||
|
settleArenaMatch,
|
||||||
|
voidArenaMatch,
|
||||||
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
|
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
|
||||||
close: () => database.close(),
|
close: () => database.close(),
|
||||||
};
|
};
|
||||||
|
|||||||
+51
-2
@@ -3,8 +3,10 @@ import { createServer } from "node:http";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
||||||
|
import { createArenaService } from "./arena-service.mjs";
|
||||||
import { publicManastormAdminConfig } from "./manastorm-config.mjs";
|
import { publicManastormAdminConfig } from "./manastorm-config.mjs";
|
||||||
import { createOnlineSessionManager } from "./online-session.mjs";
|
import { createOnlineSessionManager } from "./online-session.mjs";
|
||||||
|
import { upgradeArenaWebSocket } from "./websocket.mjs";
|
||||||
|
|
||||||
const MIME_TYPES = new Map([
|
const MIME_TYPES = new Map([
|
||||||
[".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"],
|
[".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 trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
|
||||||
const gameDatabase = options.database ?? openGameDatabase(options);
|
const gameDatabase = options.database ?? openGameDatabase(options);
|
||||||
const onlineSessions = createOnlineSessionManager();
|
const onlineSessions = createOnlineSessionManager();
|
||||||
|
const arenaService = createArenaService({ database: gameDatabase });
|
||||||
const allowAuthRequest = createAuthLimiter();
|
const allowAuthRequest = createAuthLimiter();
|
||||||
gameDatabase.pruneExpiredSessions();
|
gameDatabase.pruneExpiredSessions();
|
||||||
|
|
||||||
@@ -168,7 +171,7 @@ export function createGameServer(options = {}) {
|
|||||||
response.writeHead(204, {
|
response.writeHead(204, {
|
||||||
...cors,
|
...cors,
|
||||||
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
"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",
|
"Access-Control-Max-Age": "86400",
|
||||||
});
|
});
|
||||||
response.end();
|
response.end();
|
||||||
@@ -264,6 +267,45 @@ export function createGameServer(options = {}) {
|
|||||||
json(response, 200, onlineSessions.sync(account, groupSnapshot, body));
|
json(response, 200, onlineSessions.sync(account, groupSnapshot, body));
|
||||||
return;
|
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") {
|
if (url.pathname === "/api/manastorm-config" && request.method === "GET") {
|
||||||
const current = gameDatabase.getManastormConfig();
|
const current = gameDatabase.getManastormConfig();
|
||||||
json(response, 200, {
|
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", () => {
|
server.on("close", () => {
|
||||||
onlineSessions.clear();
|
onlineSessions.clear();
|
||||||
|
arenaService.close();
|
||||||
gameDatabase.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)) {
|
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.players.length, 2);
|
||||||
assert.equal(memberSession.events[0].targetActorId, "boss");
|
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`, {
|
const gmRegistration = await fetch(`${origin}/api/auth/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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 DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen })));
|
||||||
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
|
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
|
||||||
const ManastormSelectScreen = lazy(() => import("./ui/ManastormSelectScreen").then((module) => ({ default: module.ManastormSelectScreen })));
|
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 })));
|
const GmAdminScreen = lazy(() => import("./ui/GmAdminScreen").then((module) => ({ default: module.GmAdminScreen })));
|
||||||
|
|
||||||
function ShellTransition() {
|
function ShellTransition() {
|
||||||
@@ -72,6 +74,8 @@ export default function App() {
|
|||||||
else if (phase === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
|
else if (phase === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
|
||||||
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
|
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
|
||||||
else if (phase === "manastorms") screen = <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></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 === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><GmAdminScreen /></Suspense>;
|
||||||
else if (phase === "game") screen = <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
|
else if (phase === "game") screen = <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
|
||||||
const requiresRuntimeCatalog = !["login", "characters", "create-character"].includes(phase);
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ let pendingCharacters: CharacterProfile[] | null = null;
|
|||||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let saveQueue = Promise.resolve();
|
let saveQueue = Promise.resolve();
|
||||||
|
|
||||||
function apiOrigin(): string {
|
export function onlineApiOrigin(): string {
|
||||||
const configured = String(import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, "");
|
const configured = String(import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, "");
|
||||||
if (configured) return configured;
|
if (configured) return configured;
|
||||||
return Capacitor.isNativePlatform() ? NATIVE_API_ORIGIN : "";
|
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}`);
|
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(`${apiOrigin()}${pathname}`, { ...init, headers });
|
response = await fetch(`${onlineApiOrigin()}${pathname}`, { ...init, headers });
|
||||||
} catch {
|
} catch {
|
||||||
throw new OnlineApiError("The online server is unavailable. Check your connection or play offline.", 0);
|
throw new OnlineApiError("The online server is unavailable. Check your connection or play offline.", 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export interface ShellState {
|
|||||||
openMainMenu: () => boolean;
|
openMainMenu: () => boolean;
|
||||||
openDungeons: () => boolean;
|
openDungeons: () => boolean;
|
||||||
openManastorms: () => boolean;
|
openManastorms: () => boolean;
|
||||||
|
openArenas: () => boolean;
|
||||||
openGmAdmin: () => boolean;
|
openGmAdmin: () => boolean;
|
||||||
openClassHall: () => boolean;
|
openClassHall: () => boolean;
|
||||||
changeSelectedSpecialization: (specializationId: string, resetTalents: boolean) => boolean;
|
changeSelectedSpecialization: (specializationId: string, resetTalents: boolean) => boolean;
|
||||||
@@ -333,6 +334,15 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
|||||||
set({ phase: "manastorms", activeCharacter: null, notice: "" });
|
set({ phase: "manastorms", activeCharacter: null, notice: "" });
|
||||||
return true;
|
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: () => {
|
openGmAdmin: () => {
|
||||||
const { session, selectedCharacterId, characters } = get();
|
const { session, selectedCharacterId, characters } = get();
|
||||||
if (!isGameMasterSession(session)) {
|
if (!isGameMasterSession(session)) {
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import type { EquipmentAssignments } from "../game/equipment";
|
|||||||
import type { GameplaySettings } from "../game/combatStore";
|
import type { GameplaySettings } from "../game/combatStore";
|
||||||
import type { ManastormProgress } from "../game/manastormProgress";
|
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 SessionKind = "account" | "offline";
|
||||||
export type AccountRole = "gm";
|
export type AccountRole = "gm";
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
+352
@@ -3094,3 +3094,355 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
|||||||
@media (max-height: 560px) and (min-width: 601px) {
|
@media (max-height: 560px) and (min-width: 601px) {
|
||||||
.gm-review-card { top: auto; bottom: max(62px, calc(env(safe-area-inset-bottom) + 50px)); }
|
.gm-review-card { top: auto; bottom: max(62px, calc(env(safe-area-inset-bottom) + 50px)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Rated arenas */
|
||||||
|
.arena-select-screen {
|
||||||
|
display: grid;
|
||||||
|
grid-template: 4.4rem 1fr 2.6rem / 17.5rem 1fr;
|
||||||
|
min-height: 100%;
|
||||||
|
color: #eef5e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-select-header {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: .65rem 1rem;
|
||||||
|
border-bottom: 1px solid rgba(205, 223, 186, .18);
|
||||||
|
background: rgba(6, 15, 12, .86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-select-header > div,
|
||||||
|
.arena-select-header > p {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .7rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-select-header p {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-select-sidebar {
|
||||||
|
padding: .9rem;
|
||||||
|
overflow: auto;
|
||||||
|
border-right: 1px solid rgba(205, 223, 186, .16);
|
||||||
|
background: rgba(8, 20, 16, .88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-segmented {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: .25rem;
|
||||||
|
margin: .35rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-segmented:first-of-type {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-segmented button,
|
||||||
|
.arena-mode-list button,
|
||||||
|
.arena-stage-grid button,
|
||||||
|
.arena-loadout-editor button,
|
||||||
|
.arena-loadout-editor input,
|
||||||
|
.arena-loadout-editor select {
|
||||||
|
border: 1px solid rgba(198, 219, 177, .2);
|
||||||
|
color: inherit;
|
||||||
|
background: rgba(20, 38, 30, .9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-segmented button {
|
||||||
|
padding: .55rem .3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-segmented button.is-active,
|
||||||
|
.arena-mode-list button.is-active,
|
||||||
|
.arena-stage-grid button.is-active {
|
||||||
|
border-color: #d8b962;
|
||||||
|
background: linear-gradient(135deg, rgba(127, 102, 38, .65), rgba(29, 57, 42, .9));
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 232, 154, .16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-mode-list {
|
||||||
|
display: grid;
|
||||||
|
gap: .35rem;
|
||||||
|
margin: .35rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-mode-list button {
|
||||||
|
display: grid;
|
||||||
|
gap: .18rem;
|
||||||
|
padding: .6rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-mode-list small,
|
||||||
|
.arena-stage-grid small {
|
||||||
|
color: #aeb9ab;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-select-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.35fr) minmax(15rem, .65fr);
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
background: radial-gradient(circle at 25% 20%, rgba(54, 88, 59, .28), transparent 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-stage-browser,
|
||||||
|
.arena-configuration {
|
||||||
|
min-width: 0;
|
||||||
|
padding: .9rem;
|
||||||
|
border: 1px solid rgba(205, 223, 186, .16);
|
||||||
|
background: rgba(8, 18, 14, .76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-stage-browser > header,
|
||||||
|
.arena-configuration > header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: .6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-stage-browser h1,
|
||||||
|
.arena-configuration h2 {
|
||||||
|
margin: .12rem 0 .65rem;
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-status {
|
||||||
|
padding: .28rem .45rem;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
color: #d8b962;
|
||||||
|
font-size: .65rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-status--validated { color: #88d49a; }
|
||||||
|
.arena-status--repair-required { color: #dc8d73; }
|
||||||
|
|
||||||
|
.arena-stage-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-stage-grid button {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2rem 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
padding: .55rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-stage-grid button > b {
|
||||||
|
color: #d8b962;
|
||||||
|
font: 700 1rem Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-stage-grid button > span {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-stage-note {
|
||||||
|
margin: .7rem 0 0;
|
||||||
|
color: #c7cec2;
|
||||||
|
font-size: .74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-configuration {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-configuration input[type="range"] { width: 100%; accent-color: #d8b962; }
|
||||||
|
|
||||||
|
.arena-configuration dl {
|
||||||
|
display: grid;
|
||||||
|
gap: .3rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-configuration dl div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid rgba(205, 223, 186, .12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-loadout-editor,
|
||||||
|
.arena-loadout-editor label {
|
||||||
|
display: grid;
|
||||||
|
gap: .3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-loadout-editor {
|
||||||
|
gap: .55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-loadout-editor label {
|
||||||
|
color: #aeb9ab;
|
||||||
|
font-size: .7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-loadout-editor input,
|
||||||
|
.arena-loadout-editor select,
|
||||||
|
.arena-loadout-editor button {
|
||||||
|
padding: .48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-queue-button {
|
||||||
|
display: grid;
|
||||||
|
gap: .15rem;
|
||||||
|
margin-top: auto;
|
||||||
|
padding: .8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-runtime {
|
||||||
|
position: relative;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #f2f5ee;
|
||||||
|
background: #07100d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-world,
|
||||||
|
.arena-world > div { position: absolute; inset: 0; }
|
||||||
|
|
||||||
|
.arena-match-header {
|
||||||
|
position: absolute;
|
||||||
|
top: .7rem;
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
gap: .7rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: .45rem .7rem;
|
||||||
|
border: 1px solid rgba(225, 236, 214, .22);
|
||||||
|
background: rgba(5, 12, 9, .82);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-match-header span,
|
||||||
|
.arena-match-header em { color: #bac5b7; font-size: .72rem; font-style: normal; }
|
||||||
|
|
||||||
|
.arena-team-frames {
|
||||||
|
position: absolute;
|
||||||
|
top: 4.2rem;
|
||||||
|
display: grid;
|
||||||
|
width: min(16rem, 27vw);
|
||||||
|
gap: .3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-team-frames--0 { left: .7rem; }
|
||||||
|
.arena-team-frames--1 { right: .7rem; }
|
||||||
|
|
||||||
|
.arena-team-frames button {
|
||||||
|
display: grid;
|
||||||
|
grid-template: auto .28rem / 1fr auto;
|
||||||
|
gap: .22rem .6rem;
|
||||||
|
padding: .42rem;
|
||||||
|
border: 1px solid rgba(215, 230, 204, .2);
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
background: rgba(5, 12, 9, .82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-team-frames--1 button { text-align: right; }
|
||||||
|
.arena-team-frames button.is-dead { opacity: .38; filter: grayscale(1); }
|
||||||
|
.arena-team-frames button > span { display: grid; }
|
||||||
|
.arena-team-frames button small { color: #acb8aa; }
|
||||||
|
.arena-team-frames button i { grid-column: 1 / -1; overflow: hidden; background: #241b1b; }
|
||||||
|
.arena-team-frames button i b { display: block; height: 100%; background: #54a55d; transition: width .1s linear; }
|
||||||
|
.arena-team-frames button em { font-style: normal; }
|
||||||
|
|
||||||
|
.arena-countdown {
|
||||||
|
position: absolute;
|
||||||
|
inset: 50% auto auto 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 13rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid rgba(220, 190, 99, .5);
|
||||||
|
background: rgba(6, 13, 10, .88);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-countdown strong { color: #e1c46f; font: 700 3rem Georgia, serif; }
|
||||||
|
|
||||||
|
.arena-action-bar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: .8rem;
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
gap: .3rem;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-action-bar button {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 4.2rem;
|
||||||
|
min-height: 3.6rem;
|
||||||
|
border: 1px solid rgba(221, 232, 210, .25);
|
||||||
|
color: inherit;
|
||||||
|
background: rgba(5, 12, 9, .88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-action-bar button b { color: #dfc16b; }
|
||||||
|
.arena-action-bar button span { font-size: .65rem; text-transform: capitalize; }
|
||||||
|
|
||||||
|
.arena-exit {
|
||||||
|
position: absolute;
|
||||||
|
right: .7rem;
|
||||||
|
bottom: .8rem;
|
||||||
|
padding: .5rem;
|
||||||
|
border: 1px solid rgba(221, 232, 210, .2);
|
||||||
|
color: #d2d9cd;
|
||||||
|
background: rgba(5, 12, 9, .8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-result-panel {
|
||||||
|
position: absolute;
|
||||||
|
inset: 50% auto auto 50%;
|
||||||
|
width: min(27rem, 85vw);
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1px solid #c4a95d;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(7, 15, 11, .96);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
box-shadow: 0 1rem 5rem rgba(0, 0, 0, .55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-result-panel h1 { margin: .2rem 0 .7rem; color: #e0c26c; font: 700 2.5rem Georgia, serif; }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.arena-select-screen { grid-template: auto auto 1fr auto / 1fr; }
|
||||||
|
.arena-select-header { grid-column: 1; }
|
||||||
|
.arena-select-sidebar { border-right: 0; border-bottom: 1px solid rgba(205, 223, 186, .16); }
|
||||||
|
.arena-select-main { grid-template-columns: 1fr; }
|
||||||
|
.arena-team-frames { width: 42vw; }
|
||||||
|
.arena-match-header { left: .55rem; gap: .35rem; transform: none; }
|
||||||
|
.arena-match-header span { display: none; }
|
||||||
|
.arena-action-bar { bottom: .55rem; gap: .2rem; }
|
||||||
|
.arena-action-bar button { width: 3rem; min-height: 3.25rem; }
|
||||||
|
.arena-exit { inset: .55rem .55rem auto auto; max-width: 5.4rem; font-size: .65rem; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||||
|
import { useArenaStore } from "../game/arenaStore";
|
||||||
|
import { arenaAiProfile } from "../game/arenaRules";
|
||||||
|
import { ARENA_STAGES } from "../game/arenaStages";
|
||||||
|
import { romGearProfile, specializationById, specializationsForClass } from "../game/specializationCatalog";
|
||||||
|
import { DualDisplayFrame } from "../components/DualDisplayFrame";
|
||||||
|
import { ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||||
|
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||||
|
|
||||||
|
const MODES = [
|
||||||
|
{ id: "offline-practice", name: "Offline Practice", detail: "Choose any arena and bot rating. Results never affect rating." },
|
||||||
|
{ id: "rated-ai", name: "Rated AI", detail: "Fill both teams immediately with rating-matched opponents." },
|
||||||
|
{ id: "ranked-pvp", name: "Ranked PvP", detail: "Match humans first; all open slots lock to bots after 30 seconds." },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ArenaSelectScreen() {
|
||||||
|
const character = useShellStore(selectedCharacter);
|
||||||
|
const session = useShellStore((state) => state.session);
|
||||||
|
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||||||
|
const arena = useArenaStore();
|
||||||
|
const [loadoutName, setLoadoutName] = useState("Arena Loadout");
|
||||||
|
const specs = useMemo(() => {
|
||||||
|
if (!character) return [];
|
||||||
|
if (character.categoryId === "rom") return [romGearProfile(character.classId, "healer"), romGearProfile(character.classId, "damage")];
|
||||||
|
return specializationsForClass(character.classId).filter((spec) => spec.role !== "tank");
|
||||||
|
}, [character]);
|
||||||
|
const fallbackSpec = specs.find((spec) => spec.id === character?.specializationId) ?? specs[0] ?? null;
|
||||||
|
const [specializationId, setSpecializationId] = useState(fallbackSpec?.id ?? "");
|
||||||
|
const profile = arenaAiProfile(arena.botDifficulty);
|
||||||
|
const randomStage = arena.stageId === "random";
|
||||||
|
const selectedStage = ARENA_STAGES.find((stage) => stage.id === arena.stageId) ?? ARENA_STAGES[0];
|
||||||
|
const selectedLoadout = arena.remote?.loadouts.find((loadout) => loadout.slot === arena.selectedLoadoutSlot) ?? null;
|
||||||
|
const rating = arena.remote?.ratings[arena.bracket];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (character) void arena.load(session, character);
|
||||||
|
}, [character?.id, session?.accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fallbackSpec) return;
|
||||||
|
setSpecializationId(fallbackSpec.id);
|
||||||
|
setLoadoutName(`${fallbackSpec.displayName} PvP`);
|
||||||
|
}, [fallbackSpec?.id]);
|
||||||
|
|
||||||
|
if (!character || !selectedStage) return null;
|
||||||
|
const online = Boolean(session?.accessToken);
|
||||||
|
const rated = arena.mode !== "offline-practice";
|
||||||
|
const nativeRuleset = character.categoryId === "coa" || character.categoryId === "rom" ? character.categoryId : "wow";
|
||||||
|
const stageHeading = rated ? "Random Rated Arena" : randomStage ? "Random Arena" : selectedStage.name;
|
||||||
|
const queueLabel = arena.queue.status === "queued"
|
||||||
|
? "Leave queue"
|
||||||
|
: arena.queue.status === "ready-check"
|
||||||
|
? "Ready"
|
||||||
|
: arena.loading ? "Working..." : arena.mode === "offline-practice" ? "Start practice" : "Enter rated queue";
|
||||||
|
|
||||||
|
const primaryAction = async () => {
|
||||||
|
if (arena.queue.status === "queued") {
|
||||||
|
await arena.leave(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (arena.queue.status === "ready-check" && session) {
|
||||||
|
await arena.ready(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await arena.queueMatch(session, character);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DualDisplayFrame
|
||||||
|
contextLabel="Arena queue details"
|
||||||
|
top={(
|
||||||
|
<FrontSurface className="arena-select-screen" ariaLabel="Arena selection">
|
||||||
|
<header className="arena-select-header">
|
||||||
|
<div><span className="brand-sigil">+</span><strong>Arena Command</strong></div>
|
||||||
|
<p><small>{character.name}</small><b>{arena.bracket} / {rating ? `${rating.rating} ${rating.title}` : "Practice"}</b></p>
|
||||||
|
<button className="front-button" type="button" onClick={returnToMainMenu}>Back</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<aside className="arena-select-sidebar">
|
||||||
|
<p className="front-kicker">Format</p>
|
||||||
|
<div className="arena-segmented">
|
||||||
|
{(["2v2", "3v3"] as const).map((bracket) => (
|
||||||
|
<button key={bracket} type="button" className={arena.bracket === bracket ? "is-active" : ""} onClick={() => arena.configure({ bracket })}>{bracket}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="front-kicker">Queue</p>
|
||||||
|
<div className="arena-mode-list">
|
||||||
|
{MODES.map((mode) => (
|
||||||
|
<button key={mode.id} type="button" className={arena.mode === mode.id ? "is-active" : ""} onClick={() => arena.configure({ mode: mode.id, ...(mode.id === "offline-practice" ? {} : { ruleset: nativeRuleset }) })}>
|
||||||
|
<strong>{mode.name}</strong><small>{mode.detail}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="front-kicker">Ruleset pool</p>
|
||||||
|
<div className="arena-segmented">
|
||||||
|
{(["wow", "coa", "rom"] as const).map((ruleset) => (
|
||||||
|
<button key={ruleset} type="button" disabled={rated && ruleset !== nativeRuleset} className={arena.ruleset === ruleset ? "is-active" : ""} onClick={() => arena.configure({ ruleset })}>{ruleset.toUpperCase()}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="arena-select-main">
|
||||||
|
<section className="arena-stage-browser">
|
||||||
|
<header><div><p className="front-kicker">Eight battlegrounds</p><h1>{stageHeading}</h1></div><span className="arena-status arena-status--validated">validated</span></header>
|
||||||
|
<div className="arena-stage-grid">
|
||||||
|
<button type="button" disabled={rated} className={rated || randomStage ? "is-active" : ""} onClick={() => arena.configure({ stageId: "random" })}>
|
||||||
|
<b>00</b><span><strong>Random Arena</strong><small>Validated eight-map pool</small></span>
|
||||||
|
</button>
|
||||||
|
{ARENA_STAGES.map((stage, index) => (
|
||||||
|
<button key={stage.id} type="button" disabled={rated} className={!rated && !randomStage && stage.id === selectedStage.id ? "is-active" : ""} onClick={() => arena.configure({ stageId: stage.id })}>
|
||||||
|
<b>{String(index + 1).padStart(2, "0")}</b><span><strong>{stage.name}</strong><small>{stage.sourceMap}</small></span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="arena-stage-note">{rated ? "Rated matches choose uniformly from the validated pool and avoid your immediately previous arena when possible." : randomStage ? "Practice will select one of the eight validated arenas when the match starts." : selectedStage.validation.notes[0]}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="arena-configuration">
|
||||||
|
{arena.mode === "offline-practice" ? (
|
||||||
|
<>
|
||||||
|
<header><p className="front-kicker">Opponent calibration</p><h2>{arena.botDifficulty} / {profile.title}</h2></header>
|
||||||
|
<input aria-label="Bot rating" type="range" min="0" max="2500" step="25" value={arena.botDifficulty} onChange={(event) => arena.configure({ botDifficulty: Number(event.target.value) })} />
|
||||||
|
<dl>
|
||||||
|
<div><dt>Reaction</dt><dd>{profile.reactionMinSeconds.toFixed(2)}–{profile.reactionMaxSeconds.toFixed(2)}s</dd></div>
|
||||||
|
<div><dt>Healer kicks</dt><dd>{Math.round(profile.healerInterruptChance * 100)}%</dd></div>
|
||||||
|
<div><dt>Minimum error</dt><dd>{Math.round(profile.minimumErrorChance * 100)}%</dd></div>
|
||||||
|
</dl>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<header><p className="front-kicker">Level 80 PvP profile</p><h2>{selectedLoadout?.name ?? "Automatic first loadout"}</h2></header>
|
||||||
|
{!online && <p className="front-notice">Sign in to use rated queues. Practice remains fully offline.</p>}
|
||||||
|
{online && (
|
||||||
|
<div className="arena-loadout-editor">
|
||||||
|
<label>Name<input value={loadoutName} maxLength={32} onChange={(event) => setLoadoutName(event.target.value)} /></label>
|
||||||
|
<label>Specialization<select value={specializationId} onChange={(event) => setSpecializationId(event.target.value)}>{specs.map((spec) => <option key={spec.id} value={spec.id}>{spec.displayName} / {spec.role}</option>)}</select></label>
|
||||||
|
<label>Slot<select value={arena.selectedLoadoutSlot} onChange={(event) => arena.configure({ selectedLoadoutSlot: Number(event.target.value) })}>{Array.from({ length: 10 }, (_, index) => <option key={index + 1} value={index + 1}>{index + 1}</option>)}</select></label>
|
||||||
|
<button type="button" onClick={() => void arena.saveLoadout(session!, character, { slot: arena.selectedLoadoutSlot, name: loadoutName, specializationId })}>Save loadout</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p>Gear and prestige are replaced by versioned arena templates. Cosmetics remain visible.</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button className="front-button front-button--primary arena-queue-button" disabled={arena.loading || (rated && !online)} type="button" onClick={() => void primaryAction()}>
|
||||||
|
<strong>{queueLabel}</strong>
|
||||||
|
<small>{arena.queue.status === "queued" ? `Searching within ±${arena.queue.searchRange ?? 100}` : rated ? "15s ready check / 10s sealed preparation" : `${randomStage ? "Random arena" : selectedStage.name} / no rating changes`}</small>
|
||||||
|
</button>
|
||||||
|
{arena.error && <p className="front-notice" role="alert">{arena.error}</p>}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<ControllerLegend back />
|
||||||
|
</FrontSurface>
|
||||||
|
)}
|
||||||
|
bottom={<LazyLocalCompanionPanel />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,11 +27,11 @@ const GAME_MODES = [
|
|||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "roguelike-rpg",
|
id: "arenas",
|
||||||
number: "03",
|
number: "03",
|
||||||
name: "Roguelike RPG",
|
name: "Arenas",
|
||||||
description: "A new mode is taking shape.",
|
description: "Rated 2v2 and 3v3 combat against players or rating-matched opponents.",
|
||||||
available: false,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "trials",
|
id: "trials",
|
||||||
@@ -69,6 +69,7 @@ export function MainMenuScreen() {
|
|||||||
const rosterCharacter = useShellStore(selectedCharacter);
|
const rosterCharacter = useShellStore(selectedCharacter);
|
||||||
const openDungeons = useShellStore((state) => state.openDungeons);
|
const openDungeons = useShellStore((state) => state.openDungeons);
|
||||||
const openManastorms = useShellStore((state) => state.openManastorms);
|
const openManastorms = useShellStore((state) => state.openManastorms);
|
||||||
|
const openArenas = useShellStore((state) => state.openArenas);
|
||||||
const openClassHall = useShellStore((state) => state.openClassHall);
|
const openClassHall = useShellStore((state) => state.openClassHall);
|
||||||
const returnToCharacters = useShellStore((state) => state.returnToCharacters);
|
const returnToCharacters = useShellStore((state) => state.returnToCharacters);
|
||||||
const notice = useShellStore((state) => state.notice);
|
const notice = useShellStore((state) => state.notice);
|
||||||
@@ -89,6 +90,11 @@ export function MainMenuScreen() {
|
|||||||
run: openManastorms,
|
run: openManastorms,
|
||||||
neighbors: { left: "mode-dungeons", down: "back-to-roster" },
|
neighbors: { left: "mode-dungeons", down: "back-to-roster" },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "mode-arenas",
|
||||||
|
run: openArenas,
|
||||||
|
neighbors: { left: "mode-manastorms", down: "back-to-roster" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "mode-downloads",
|
id: "mode-downloads",
|
||||||
run: () => setContentManagerOpen(true),
|
run: () => setContentManagerOpen(true),
|
||||||
@@ -101,7 +107,7 @@ export function MainMenuScreen() {
|
|||||||
},
|
},
|
||||||
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []),
|
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []),
|
||||||
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
|
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
|
||||||
], [character, isGm, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
], [character, isGm, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||||
const controller = useMenuController(actions, {
|
const controller = useMenuController(actions, {
|
||||||
initialId: "mode-dungeons",
|
initialId: "mode-dungeons",
|
||||||
onBack: returnToCharacters,
|
onBack: returnToCharacters,
|
||||||
@@ -197,6 +203,8 @@ export function MainMenuScreen() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={mode.id === "manastorms"
|
onClick={mode.id === "manastorms"
|
||||||
? openManastorms
|
? openManastorms
|
||||||
|
: mode.id === "arenas"
|
||||||
|
? openArenas
|
||||||
: mode.id === "downloads"
|
: mode.id === "downloads"
|
||||||
? () => setContentManagerOpen(true)
|
? () => setContentManagerOpen(true)
|
||||||
: openDungeons}
|
: openDungeons}
|
||||||
|
|||||||
Reference in new issue
Block a user