diff --git a/dungeon-pipeline/recipes/blades-edge-arena.json b/dungeon-pipeline/recipes/blades-edge-arena.json new file mode 100644 index 00000000..8fb2295e --- /dev/null +++ b/dungeon-pipeline/recipes/blades-edge-arena.json @@ -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": [] +} diff --git a/dungeon-pipeline/recipes/dalaran-sewers.json b/dungeon-pipeline/recipes/dalaran-sewers.json new file mode 100644 index 00000000..ec7fe8be --- /dev/null +++ b/dungeon-pipeline/recipes/dalaran-sewers.json @@ -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": [] +} diff --git a/dungeon-pipeline/recipes/nagrand-arena.json b/dungeon-pipeline/recipes/nagrand-arena.json new file mode 100644 index 00000000..0a7d819b --- /dev/null +++ b/dungeon-pipeline/recipes/nagrand-arena.json @@ -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": [] +} diff --git a/dungeon-pipeline/recipes/ring-of-valor.json b/dungeon-pipeline/recipes/ring-of-valor.json new file mode 100644 index 00000000..c09d59e2 --- /dev/null +++ b/dungeon-pipeline/recipes/ring-of-valor.json @@ -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": [] +} diff --git a/dungeon-pipeline/recipes/ruins-of-lordaeron.json b/dungeon-pipeline/recipes/ruins-of-lordaeron.json new file mode 100644 index 00000000..87ead428 --- /dev/null +++ b/dungeon-pipeline/recipes/ruins-of-lordaeron.json @@ -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": [] +} diff --git a/package-lock.json b/package-lock.json index 46fc158c..974b4130 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.15", + "version": "0.1.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.15", + "version": "0.1.16", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index 927f5a2f..1fd7be0e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.15", + "version": "0.1.16", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", @@ -63,6 +63,8 @@ "dungeon:navmesh": "node scripts/dungeon-pipeline/cli.mjs navmesh", "dungeon:navmesh:conversion": "node scripts/dungeon-pipeline/cli.mjs navmesh-conversion", "dungeon:validate": "node scripts/dungeon-pipeline/cli.mjs validate", + "arena:assets:build": "node scripts/arena-pipeline/build-wmo-arenas.mjs && node scripts/arena-pipeline/build-custom-arenas.mjs && node scripts/arena-pipeline/prepare-warnorken.mjs", + "arena:assets:validate": "node scripts/arena-pipeline/validate-arenas.mjs", "dungeon:enable": "node scripts/dungeon-pipeline/cli.mjs enable", "runewaker:forsaken": "node \"%INIT_CWD%\\scripts\\runewaker-pipeline\\build-forsaken-abbey.mjs\"", "runewaker:forsaken:actors": "node scripts/runewaker-pipeline/build-forsaken-population.mjs --actors-only", @@ -98,7 +100,7 @@ "assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs", "balance:benchmark": "vite-node scripts/combat-benchmark/run.ts", "balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2", - "prebuild": "npm run loot:generate && npm run web:prepare && npm run assets:ktx2:audit && npm run assets:performance:audit", + "prebuild": "npm run loot:generate && npm run web:prepare && npm run arena:assets:validate && npm run assets:ktx2:audit && npm run assets:performance:audit", "build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build", "build:android:web": "npm run prebuild && npm run android:prepare && node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build --mode android", "android:sync": "npm run build:android:web && cap sync android", diff --git a/public/assets/game/arenas/ashamanes-fall/collision.glb b/public/assets/game/arenas/ashamanes-fall/collision.glb new file mode 100644 index 00000000..d4fdfd9c Binary files /dev/null and b/public/assets/game/arenas/ashamanes-fall/collision.glb differ diff --git a/public/assets/game/arenas/ashamanes-fall/navigation.glb b/public/assets/game/arenas/ashamanes-fall/navigation.glb new file mode 100644 index 00000000..53a94b44 Binary files /dev/null and b/public/assets/game/arenas/ashamanes-fall/navigation.glb differ diff --git a/public/assets/game/arenas/ashamanes-fall/recast-report.json b/public/assets/game/arenas/ashamanes-fall/recast-report.json new file mode 100644 index 00000000..bd50be0f --- /dev/null +++ b/public/assets/game/arenas/ashamanes-fall/recast-report.json @@ -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" + ] + } +} diff --git a/public/assets/game/arenas/ashamanes-fall/stage-pack.json b/public/assets/game/arenas/ashamanes-fall/stage-pack.json new file mode 100644 index 00000000..3e92cf3f --- /dev/null +++ b/public/assets/game/arenas/ashamanes-fall/stage-pack.json @@ -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" + } + ] +} diff --git a/public/assets/game/arenas/ashamanes-fall/visual.glb b/public/assets/game/arenas/ashamanes-fall/visual.glb new file mode 100644 index 00000000..b3d77b1e Binary files /dev/null and b/public/assets/game/arenas/ashamanes-fall/visual.glb differ diff --git a/public/assets/game/arenas/black-rook-hold-arena/collision-001.glb b/public/assets/game/arenas/black-rook-hold-arena/collision-001.glb new file mode 100644 index 00000000..2707070d Binary files /dev/null and b/public/assets/game/arenas/black-rook-hold-arena/collision-001.glb differ diff --git a/public/assets/game/arenas/black-rook-hold-arena/collision-002.glb b/public/assets/game/arenas/black-rook-hold-arena/collision-002.glb new file mode 100644 index 00000000..1f7da31d Binary files /dev/null and b/public/assets/game/arenas/black-rook-hold-arena/collision-002.glb differ diff --git a/public/assets/game/arenas/black-rook-hold-arena/collision-003.glb b/public/assets/game/arenas/black-rook-hold-arena/collision-003.glb new file mode 100644 index 00000000..c99b86a3 Binary files /dev/null and b/public/assets/game/arenas/black-rook-hold-arena/collision-003.glb differ diff --git a/public/assets/game/arenas/black-rook-hold-arena/collision.glb b/public/assets/game/arenas/black-rook-hold-arena/collision.glb new file mode 100644 index 00000000..0ba793ba Binary files /dev/null and b/public/assets/game/arenas/black-rook-hold-arena/collision.glb differ diff --git a/public/assets/game/arenas/black-rook-hold-arena/navigation.glb b/public/assets/game/arenas/black-rook-hold-arena/navigation.glb new file mode 100644 index 00000000..258b00f4 Binary files /dev/null and b/public/assets/game/arenas/black-rook-hold-arena/navigation.glb differ diff --git a/public/assets/game/arenas/black-rook-hold-arena/recast-report.json b/public/assets/game/arenas/black-rook-hold-arena/recast-report.json new file mode 100644 index 00000000..3a4c01b4 --- /dev/null +++ b/public/assets/game/arenas/black-rook-hold-arena/recast-report.json @@ -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" + ] + } +} diff --git a/public/assets/game/arenas/black-rook-hold-arena/stage-pack.json b/public/assets/game/arenas/black-rook-hold-arena/stage-pack.json new file mode 100644 index 00000000..f0758e66 --- /dev/null +++ b/public/assets/game/arenas/black-rook-hold-arena/stage-pack.json @@ -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" + } + ] +} diff --git a/public/assets/game/arenas/black-rook-hold-arena/visual.glb b/public/assets/game/arenas/black-rook-hold-arena/visual.glb new file mode 100644 index 00000000..b0682320 Binary files /dev/null and b/public/assets/game/arenas/black-rook-hold-arena/visual.glb differ diff --git a/public/assets/game/arenas/blades-edge-arena/collision.glb b/public/assets/game/arenas/blades-edge-arena/collision.glb new file mode 100644 index 00000000..26ef6602 Binary files /dev/null and b/public/assets/game/arenas/blades-edge-arena/collision.glb differ diff --git a/public/assets/game/arenas/blades-edge-arena/navigation.glb b/public/assets/game/arenas/blades-edge-arena/navigation.glb new file mode 100644 index 00000000..9649448b Binary files /dev/null and b/public/assets/game/arenas/blades-edge-arena/navigation.glb differ diff --git a/public/assets/game/arenas/blades-edge-arena/recast-report.json b/public/assets/game/arenas/blades-edge-arena/recast-report.json new file mode 100644 index 00000000..1c9b631c --- /dev/null +++ b/public/assets/game/arenas/blades-edge-arena/recast-report.json @@ -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" + } +} diff --git a/public/assets/game/arenas/blades-edge-arena/stage-pack.json b/public/assets/game/arenas/blades-edge-arena/stage-pack.json new file mode 100644 index 00000000..90375576 --- /dev/null +++ b/public/assets/game/arenas/blades-edge-arena/stage-pack.json @@ -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" + } + ] +} diff --git a/public/assets/game/arenas/blades-edge-arena/visual.glb b/public/assets/game/arenas/blades-edge-arena/visual.glb new file mode 100644 index 00000000..f2c493e1 Binary files /dev/null and b/public/assets/game/arenas/blades-edge-arena/visual.glb differ diff --git a/public/assets/game/arenas/dalaran-sewers/collision.glb b/public/assets/game/arenas/dalaran-sewers/collision.glb new file mode 100644 index 00000000..2e17cb5d Binary files /dev/null and b/public/assets/game/arenas/dalaran-sewers/collision.glb differ diff --git a/public/assets/game/arenas/dalaran-sewers/navigation.glb b/public/assets/game/arenas/dalaran-sewers/navigation.glb new file mode 100644 index 00000000..3bb5cd09 Binary files /dev/null and b/public/assets/game/arenas/dalaran-sewers/navigation.glb differ diff --git a/public/assets/game/arenas/dalaran-sewers/recast-report.json b/public/assets/game/arenas/dalaran-sewers/recast-report.json new file mode 100644 index 00000000..2c04d6c0 --- /dev/null +++ b/public/assets/game/arenas/dalaran-sewers/recast-report.json @@ -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" + } +} diff --git a/public/assets/game/arenas/dalaran-sewers/stage-pack.json b/public/assets/game/arenas/dalaran-sewers/stage-pack.json new file mode 100644 index 00000000..503bec5a --- /dev/null +++ b/public/assets/game/arenas/dalaran-sewers/stage-pack.json @@ -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" + } + ] +} diff --git a/public/assets/game/arenas/dalaran-sewers/visual.glb b/public/assets/game/arenas/dalaran-sewers/visual.glb new file mode 100644 index 00000000..4e4e2f62 Binary files /dev/null and b/public/assets/game/arenas/dalaran-sewers/visual.glb differ diff --git a/public/assets/game/arenas/nagrand-arena/collision.glb b/public/assets/game/arenas/nagrand-arena/collision.glb new file mode 100644 index 00000000..d3fa4084 Binary files /dev/null and b/public/assets/game/arenas/nagrand-arena/collision.glb differ diff --git a/public/assets/game/arenas/nagrand-arena/navigation.glb b/public/assets/game/arenas/nagrand-arena/navigation.glb new file mode 100644 index 00000000..373affc6 Binary files /dev/null and b/public/assets/game/arenas/nagrand-arena/navigation.glb differ diff --git a/public/assets/game/arenas/nagrand-arena/recast-report.json b/public/assets/game/arenas/nagrand-arena/recast-report.json new file mode 100644 index 00000000..918127e5 --- /dev/null +++ b/public/assets/game/arenas/nagrand-arena/recast-report.json @@ -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" + } +} diff --git a/public/assets/game/arenas/nagrand-arena/stage-pack.json b/public/assets/game/arenas/nagrand-arena/stage-pack.json new file mode 100644 index 00000000..957617ab --- /dev/null +++ b/public/assets/game/arenas/nagrand-arena/stage-pack.json @@ -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" + } + ] +} diff --git a/public/assets/game/arenas/nagrand-arena/visual.glb b/public/assets/game/arenas/nagrand-arena/visual.glb new file mode 100644 index 00000000..6a551423 Binary files /dev/null and b/public/assets/game/arenas/nagrand-arena/visual.glb differ diff --git a/public/assets/game/arenas/ring-of-valor/collision.glb b/public/assets/game/arenas/ring-of-valor/collision.glb new file mode 100644 index 00000000..98b3fef6 Binary files /dev/null and b/public/assets/game/arenas/ring-of-valor/collision.glb differ diff --git a/public/assets/game/arenas/ring-of-valor/navigation.glb b/public/assets/game/arenas/ring-of-valor/navigation.glb new file mode 100644 index 00000000..fa601960 Binary files /dev/null and b/public/assets/game/arenas/ring-of-valor/navigation.glb differ diff --git a/public/assets/game/arenas/ring-of-valor/recast-report.json b/public/assets/game/arenas/ring-of-valor/recast-report.json new file mode 100644 index 00000000..8dfd7045 --- /dev/null +++ b/public/assets/game/arenas/ring-of-valor/recast-report.json @@ -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" + } +} diff --git a/public/assets/game/arenas/ring-of-valor/stage-pack.json b/public/assets/game/arenas/ring-of-valor/stage-pack.json new file mode 100644 index 00000000..43884fc3 --- /dev/null +++ b/public/assets/game/arenas/ring-of-valor/stage-pack.json @@ -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" + } + ] +} diff --git a/public/assets/game/arenas/ring-of-valor/visual.glb b/public/assets/game/arenas/ring-of-valor/visual.glb new file mode 100644 index 00000000..00bd9021 Binary files /dev/null and b/public/assets/game/arenas/ring-of-valor/visual.glb differ diff --git a/public/assets/game/arenas/ruins-of-lordaeron/collision.glb b/public/assets/game/arenas/ruins-of-lordaeron/collision.glb new file mode 100644 index 00000000..0937378f Binary files /dev/null and b/public/assets/game/arenas/ruins-of-lordaeron/collision.glb differ diff --git a/public/assets/game/arenas/ruins-of-lordaeron/navigation.glb b/public/assets/game/arenas/ruins-of-lordaeron/navigation.glb new file mode 100644 index 00000000..3a7d5df1 Binary files /dev/null and b/public/assets/game/arenas/ruins-of-lordaeron/navigation.glb differ diff --git a/public/assets/game/arenas/ruins-of-lordaeron/recast-report.json b/public/assets/game/arenas/ruins-of-lordaeron/recast-report.json new file mode 100644 index 00000000..677b7745 --- /dev/null +++ b/public/assets/game/arenas/ruins-of-lordaeron/recast-report.json @@ -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" + } +} diff --git a/public/assets/game/arenas/ruins-of-lordaeron/stage-pack.json b/public/assets/game/arenas/ruins-of-lordaeron/stage-pack.json new file mode 100644 index 00000000..549787c5 --- /dev/null +++ b/public/assets/game/arenas/ruins-of-lordaeron/stage-pack.json @@ -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" + } + ] +} diff --git a/public/assets/game/arenas/ruins-of-lordaeron/visual.glb b/public/assets/game/arenas/ruins-of-lordaeron/visual.glb new file mode 100644 index 00000000..741da941 Binary files /dev/null and b/public/assets/game/arenas/ruins-of-lordaeron/visual.glb differ diff --git a/public/assets/game/arenas/warnorken-arena/collision.glb b/public/assets/game/arenas/warnorken-arena/collision.glb new file mode 100644 index 00000000..000f0da7 Binary files /dev/null and b/public/assets/game/arenas/warnorken-arena/collision.glb differ diff --git a/public/assets/game/arenas/warnorken-arena/navigation-source.glb b/public/assets/game/arenas/warnorken-arena/navigation-source.glb new file mode 100644 index 00000000..613bf052 Binary files /dev/null and b/public/assets/game/arenas/warnorken-arena/navigation-source.glb differ diff --git a/public/assets/game/arenas/warnorken-arena/navigation.glb b/public/assets/game/arenas/warnorken-arena/navigation.glb new file mode 100644 index 00000000..9100764b Binary files /dev/null and b/public/assets/game/arenas/warnorken-arena/navigation.glb differ diff --git a/public/assets/game/arenas/warnorken-arena/stage-pack.json b/public/assets/game/arenas/warnorken-arena/stage-pack.json new file mode 100644 index 00000000..14c488c1 --- /dev/null +++ b/public/assets/game/arenas/warnorken-arena/stage-pack.json @@ -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" + } + ] +} diff --git a/public/assets/game/arenas/warnorken-arena/visual.glb b/public/assets/game/arenas/warnorken-arena/visual.glb new file mode 100644 index 00000000..7d2e2c09 Binary files /dev/null and b/public/assets/game/arenas/warnorken-arena/visual.glb differ diff --git a/release-version.json b/release-version.json index e5ceb94e..f73d321d 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.15", - "versionCode": 1015 + "versionName": "0.1.16", + "versionCode": 1016 } diff --git a/scripts/arena-pipeline/build-custom-arenas.mjs b/scripts/arena-pipeline/build-custom-arenas.mjs new file mode 100644 index 00000000..6206c3a7 --- /dev/null +++ b/scripts/arena-pipeline/build-custom-arenas.mjs @@ -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); +} diff --git a/scripts/arena-pipeline/build-wmo-arenas.mjs b/scripts/arena-pipeline/build-wmo-arenas.mjs new file mode 100644 index 00000000..6cb6e575 --- /dev/null +++ b/scripts/arena-pipeline/build-wmo-arenas.mjs @@ -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`); + } +} diff --git a/scripts/arena-pipeline/extract-navigation-component.mjs b/scripts/arena-pipeline/extract-navigation-component.mjs new file mode 100644 index 00000000..008c4c2d --- /dev/null +++ b/scripts/arena-pipeline/extract-navigation-component.mjs @@ -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, + }; +} diff --git a/scripts/arena-pipeline/inspect-navigation.mjs b/scripts/arena-pipeline/inspect-navigation.mjs new file mode 100644 index 00000000..d2ddf96a --- /dev/null +++ b/scripts/arena-pipeline/inspect-navigation.mjs @@ -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])))), + })), + })); +} diff --git a/scripts/arena-pipeline/prepare-warnorken.mjs b/scripts/arena-pipeline/prepare-warnorken.mjs new file mode 100644 index 00000000..82f6dc98 --- /dev/null +++ b/scripts/arena-pipeline/prepare-warnorken.mjs @@ -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`); diff --git a/scripts/arena-pipeline/validate-arenas.mjs b/scripts/arena-pipeline/validate-arenas.mjs new file mode 100644 index 00000000..39c4c891 --- /dev/null +++ b/scripts/arena-pipeline/validate-arenas.mjs @@ -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; diff --git a/scripts/dungeon-pipeline/recast-bake.mjs b/scripts/dungeon-pipeline/recast-bake.mjs index 65b995cb..30d8fec9 100644 --- a/scripts/dungeon-pipeline/recast-bake.mjs +++ b/scripts/dungeon-pipeline/recast-bake.mjs @@ -13,6 +13,7 @@ import { import { threeToSoloNavMesh, threeToTiledNavMesh, NavMeshHelper } from "@recast-navigation/three"; import { Vector3 } from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { MeshoptDecoder } from "meshoptimizer"; import { exists, fixturesRoot, @@ -39,9 +40,10 @@ globalThis.ProgressEvent ??= class ProgressEvent { globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} }); async function loadScene(file) { + await MeshoptDecoder.ready; const bytes = await readFile(file); const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); - return new Promise((resolve, reject) => new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject)); + return new Promise((resolve, reject) => new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).parse(buffer, "", (gltf) => resolve(gltf.scene), reject)); } function meshesIn(scene) { diff --git a/server/arena-database.test.mjs b/server/arena-database.test.mjs new file mode 100644 index 00000000..6cc4affb --- /dev/null +++ b/server/arena-database.test.mjs @@ -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 }); + } +}); diff --git a/server/arena-rules.mjs b/server/arena-rules.mjs new file mode 100644 index 00000000..2e31d8bd --- /dev/null +++ b/server/arena-rules.mjs @@ -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), + }; +} diff --git a/server/arena-rules.test.mjs b/server/arena-rules.test.mjs new file mode 100644 index 00000000..8f7125e5 --- /dev/null +++ b/server/arena-rules.test.mjs @@ -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`); + } +}); diff --git a/server/arena-service.mjs b/server/arena-service.mjs new file mode 100644 index 00000000..020e09ce --- /dev/null +++ b/server/arena-service.mjs @@ -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 }), + }; +} diff --git a/server/arena-service.test.mjs b/server/arena-service.test.mjs new file mode 100644 index 00000000..39c39269 --- /dev/null +++ b/server/arena-service.test.mjs @@ -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(); +}); diff --git a/server/arena-simulation.d.mts b/server/arena-simulation.d.mts new file mode 100644 index 00000000..73acead2 --- /dev/null +++ b/server/arena-simulation.d.mts @@ -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) => 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 }; +} diff --git a/server/arena-simulation.mjs b/server/arena-simulation.mjs new file mode 100644 index 00000000..21013a93 --- /dev/null +++ b/server/arena-simulation.mjs @@ -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(), + }; + } +} diff --git a/server/arena-simulation.test.mjs b/server/arena-simulation.test.mjs new file mode 100644 index 00000000..1a629c0c --- /dev/null +++ b/server/arena-simulation.test.mjs @@ -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"); +}); diff --git a/server/arena-specializations.mjs b/server/arena-specializations.mjs new file mode 100644 index 00000000..f1990644 --- /dev/null +++ b/server/arena-specializations.mjs @@ -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"; +} diff --git a/server/arena-stages.mjs b/server/arena-stages.mjs new file mode 100644 index 00000000..832395b2 --- /dev/null +++ b/server/arena-stages.mjs @@ -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)]; +} diff --git a/server/arena-stages.test.mjs b/server/arena-stages.test.mjs new file mode 100644 index 00000000..1e9d1c2e --- /dev/null +++ b/server/arena-stages.test.mjs @@ -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); + } +}); diff --git a/server/database.mjs b/server/database.mjs index ef6b4547..66239426 100644 --- a/server/database.mjs +++ b/server/database.mjs @@ -8,6 +8,8 @@ import { normalizeManastormAdminConfig, stampManastormReviewMetadata, } from "./manastorm-config.mjs"; +import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena-rules.mjs"; +import { arenaRoleForSpecialization } from "./arena-specializations.mjs"; const scrypt = promisify(scryptCallback); @@ -115,7 +117,64 @@ export function openGameDatabase(options = {}) { ) STRICT; CREATE INDEX IF NOT EXISTS online_group_invites_invitee_idx ON online_group_invites(invitee_account_id, status, expires_at); + CREATE TABLE IF NOT EXISTS arena_loadouts ( + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + character_id TEXT NOT NULL, + slot INTEGER NOT NULL CHECK (slot BETWEEN 1 AND 10), + name TEXT NOT NULL, + class_id TEXT NOT NULL, + specialization_id TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('healer', 'damage')), + ruleset TEXT NOT NULL CHECK (ruleset IN ('wow', 'coa', 'rom')), + level INTEGER NOT NULL CHECK (level = 80), + talents_json TEXT NOT NULL, + actions_json TEXT NOT NULL, + version INTEGER NOT NULL, + selected INTEGER NOT NULL CHECK (selected IN (0, 1)), + updated_at INTEGER NOT NULL, + PRIMARY KEY (account_id, character_id, slot) + ) STRICT; + CREATE INDEX IF NOT EXISTS arena_loadouts_character_idx + ON arena_loadouts(account_id, character_id, selected); + CREATE TABLE IF NOT EXISTS arena_ratings ( + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + character_id TEXT NOT NULL, + bracket TEXT NOT NULL CHECK (bracket IN ('2v2', '3v3')), + rating INTEGER NOT NULL CHECK (rating BETWEEN 0 AND 2500), + updated_at INTEGER NOT NULL, + PRIMARY KEY (account_id, character_id, bracket) + ) STRICT; + CREATE TABLE IF NOT EXISTS arena_matches ( + id TEXT PRIMARY KEY, + bracket TEXT NOT NULL CHECK (bracket IN ('2v2', '3v3')), + mode TEXT NOT NULL CHECK (mode IN ('rated-ai', 'ranked-pvp')), + ruleset TEXT NOT NULL CHECK (ruleset IN ('wow', 'coa', 'rom')), + stage_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'settled', 'void')), + winner_team INTEGER CHECK (winner_team IN (0, 1)), + started_at INTEGER NOT NULL, + settled_at INTEGER + ) STRICT; + CREATE TABLE IF NOT EXISTS arena_match_participants ( + match_id TEXT NOT NULL REFERENCES arena_matches(id) ON DELETE CASCADE, + actor_id TEXT NOT NULL, + account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL, + character_id TEXT, + team INTEGER NOT NULL CHECK (team IN (0, 1)), + role TEXT NOT NULL CHECK (role IN ('healer', 'damage')), + is_bot INTEGER NOT NULL CHECK (is_bot IN (0, 1)), + disconnected INTEGER NOT NULL DEFAULT 0 CHECK (disconnected IN (0, 1)), + rating_before INTEGER, + rating_delta INTEGER, + rating_after INTEGER, + PRIMARY KEY (match_id, actor_id), + UNIQUE (match_id, account_id, character_id) + ) STRICT; + CREATE INDEX IF NOT EXISTS arena_matches_status_idx ON arena_matches(status, started_at); `); + database.prepare(` + UPDATE arena_matches SET status = 'void', settled_at = ? WHERE status = 'active' + `).run(Date.now()); database.prepare(` INSERT OR IGNORE INTO manastorm_admin_config ( id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at @@ -152,6 +211,23 @@ export function openGameDatabase(options = {}) { WHERE id = 1 AND revision = ? RETURNING revision, updated_at `), + arenaLoadouts: database.prepare(` + SELECT * FROM arena_loadouts WHERE account_id = ? AND character_id = ? ORDER BY slot + `), + arenaLoadout: database.prepare(` + SELECT * FROM arena_loadouts WHERE account_id = ? AND character_id = ? AND slot = ? + `), + selectedArenaLoadout: database.prepare(` + SELECT * FROM arena_loadouts WHERE account_id = ? AND character_id = ? + ORDER BY selected DESC, slot ASC LIMIT 1 + `), + arenaRating: database.prepare(` + SELECT rating, updated_at FROM arena_ratings WHERE account_id = ? AND character_id = ? AND bracket = ? + `), + upsertArenaRating: database.prepare(` + INSERT INTO arena_ratings (account_id, character_id, bracket, rating, updated_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(account_id, character_id, bracket) DO UPDATE SET rating = excluded.rating, updated_at = excluded.updated_at + `), }; function issueSession(accountId) { @@ -563,6 +639,260 @@ export function openGameDatabase(options = {}) { return onlineGroupState(accountId); } + function arenaCharacter(accountId, characterId) { + const save = getCloudSave(accountId); + const characters = Array.isArray(save.data?.characters) ? save.data.characters : []; + const character = characters.find((entry) => String(entry?.id ?? "") === characterId); + if (!character) throw new GameDatabaseError("That character is not in your roster.", 404, "arena_character_not_found"); + return character; + } + + function parseArenaLoadout(row) { + return { + slot: Number(row.slot), + name: row.name, + characterId: row.character_id, + classId: row.class_id, + specializationId: row.specialization_id, + role: row.role, + ruleset: row.ruleset, + level: 80, + talents: parseStoredJson(row.talents_json, {}), + actions: parseStoredJson(row.actions_json, {}), + version: Number(row.version), + selected: Boolean(row.selected), + updatedAt: Number(row.updated_at), + }; + } + + function validateArenaRecord(value, name, maxEntries, maxLength) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new GameDatabaseError(`${name} must be an object.`, 400, `invalid_arena_${name.toLowerCase()}`); + } + const entries = Object.entries(value); + if (entries.length > maxEntries) { + throw new GameDatabaseError(`${name} has too many entries.`, 400, `invalid_arena_${name.toLowerCase()}`); + } + const json = JSON.stringify(value); + if (json.length > maxLength) { + throw new GameDatabaseError(`${name} is too large.`, 400, `invalid_arena_${name.toLowerCase()}`); + } + return json; + } + + function putArenaLoadout(accountId, characterIdInput, slotInput, input) { + const characterId = String(characterIdInput ?? "").trim().slice(0, 100); + const slot = Math.trunc(Number(slotInput)); + if (!characterId || !Number.isInteger(slot) || slot < 1 || slot > 10) { + throw new GameDatabaseError("Choose loadout slot 1 through 10.", 400, "invalid_arena_loadout_slot"); + } + const character = arenaCharacter(accountId, characterId); + const classId = String(input?.classId ?? character.classId ?? "").trim().toLowerCase().slice(0, 50); + if (!classId || classId !== String(character.classId ?? "").trim().toLowerCase()) { + throw new GameDatabaseError("The loadout specialization must belong to this character's class.", 400, "arena_class_mismatch"); + } + const ruleset = String(input?.ruleset ?? character.categoryId ?? "wow").trim().toLowerCase(); + if (!["wow", "coa", "rom"].includes(ruleset)) { + throw new GameDatabaseError("Choose a valid arena ruleset.", 400, "invalid_arena_ruleset"); + } + const specializationId = String(input?.specializationId ?? "").trim().toLowerCase().slice(0, 100); + const role = arenaRoleForSpecialization(classId, specializationId, ruleset); + if (role === "tank") throw new GameDatabaseError("Tank specializations cannot enter rated arenas.", 400, "arena_tank_forbidden"); + if (!role || !["healer", "damage"].includes(role)) { + throw new GameDatabaseError("Choose a valid healer or damage specialization.", 400, "invalid_arena_specialization"); + } + if (input?.role && input.role !== role) { + throw new GameDatabaseError("The selected role does not match that specialization.", 400, "arena_role_mismatch"); + } + const name = String(input?.name ?? `Loadout ${slot}`).trim().slice(0, 32); + if (!name) throw new GameDatabaseError("Name this PvP loadout.", 400, "invalid_arena_loadout_name"); + const talentsJson = validateArenaRecord(input?.talents ?? {}, "Talents", 100, 16 * 1024); + const actionsJson = validateArenaRecord(input?.actions ?? {}, "Actions", 120, 24 * 1024); + const previous = statements.arenaLoadout.get(accountId, characterId, slot); + const version = Number(previous?.version ?? 0) + 1; + const selected = input?.selected === true || !statements.arenaLoadouts.all(accountId, characterId).length; + const now = Date.now(); + database.exec("BEGIN IMMEDIATE"); + try { + if (selected) database.prepare("UPDATE arena_loadouts SET selected = 0 WHERE account_id = ? AND character_id = ?").run(accountId, characterId); + database.prepare(` + INSERT INTO arena_loadouts ( + account_id, character_id, slot, name, class_id, specialization_id, role, ruleset, + level, talents_json, actions_json, version, selected, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 80, ?, ?, ?, ?, ?) + ON CONFLICT(account_id, character_id, slot) DO UPDATE SET + name = excluded.name, class_id = excluded.class_id, + specialization_id = excluded.specialization_id, role = excluded.role, + ruleset = excluded.ruleset, level = 80, talents_json = excluded.talents_json, + actions_json = excluded.actions_json, version = excluded.version, + selected = excluded.selected, updated_at = excluded.updated_at + `).run( + accountId, characterId, slot, name, classId, specializationId, role, ruleset, + talentsJson, actionsJson, version, selected ? 1 : 0, now, + ); + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } + return parseArenaLoadout(statements.arenaLoadout.get(accountId, characterId, slot)); + } + + function deleteArenaLoadout(accountId, characterIdInput, slotInput) { + const characterId = String(characterIdInput ?? "").trim().slice(0, 100); + const slot = Math.trunc(Number(slotInput)); + arenaCharacter(accountId, characterId); + const previous = statements.arenaLoadout.get(accountId, characterId, slot); + if (!previous) throw new GameDatabaseError("That PvP loadout does not exist.", 404, "arena_loadout_not_found"); + database.exec("BEGIN IMMEDIATE"); + try { + database.prepare("DELETE FROM arena_loadouts WHERE account_id = ? AND character_id = ? AND slot = ?").run(accountId, characterId, slot); + if (previous.selected) { + database.prepare(` + UPDATE arena_loadouts SET selected = 1 + WHERE account_id = ? AND character_id = ? AND slot = ( + SELECT MIN(slot) FROM arena_loadouts WHERE account_id = ? AND character_id = ? + ) + `).run(accountId, characterId, accountId, characterId); + } + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } + return { ok: true }; + } + + function getArenaRating(accountId, characterId, bracket) { + if (!["2v2", "3v3"].includes(bracket)) throw new GameDatabaseError("Choose 2v2 or 3v3.", 400, "invalid_arena_bracket"); + const row = statements.arenaRating.get(accountId, characterId, bracket); + const rating = clampArenaRating(row?.rating ?? 0); + return { bracket, rating, title: arenaRatingBand(rating).title, updatedAt: row ? Number(row.updated_at) : null }; + } + + function getArenaState(accountId, characterIdInput = null) { + const cloud = getCloudSave(accountId); + const roster = Array.isArray(cloud.data?.characters) ? cloud.data.characters : []; + const requestedId = String(characterIdInput ?? "").trim(); + const character = requestedId ? arenaCharacter(accountId, requestedId) : roster[0] ?? null; + if (!character) return { characterId: null, loadouts: [], ratings: { "2v2": null, "3v3": null } }; + const characterId = String(character.id); + return { + characterId, + loadouts: statements.arenaLoadouts.all(accountId, characterId).map(parseArenaLoadout), + ratings: { + "2v2": getArenaRating(accountId, characterId, "2v2"), + "3v3": getArenaRating(accountId, characterId, "3v3"), + }, + }; + } + + function arenaLoadoutForQueue(accountId, characterId, slot) { + arenaCharacter(accountId, characterId); + const row = statements.arenaLoadout.get(accountId, characterId, Math.trunc(Number(slot))); + if (!row) throw new GameDatabaseError("Create that PvP loadout before queueing.", 409, "arena_loadout_required"); + return parseArenaLoadout(row); + } + + function selectedArenaLoadoutForQueue(accountId, characterId) { + arenaCharacter(accountId, characterId); + const row = statements.selectedArenaLoadout.get(accountId, characterId); + if (!row) throw new GameDatabaseError("Create and select a PvP loadout before queueing.", 409, "arena_loadout_required"); + return parseArenaLoadout(row); + } + + function createArenaMatch(match) { + database.exec("BEGIN IMMEDIATE"); + try { + database.prepare(` + INSERT INTO arena_matches (id, bracket, mode, ruleset, stage_id, status, winner_team, started_at, settled_at) + VALUES (?, ?, ?, ?, ?, 'active', NULL, ?, NULL) + `).run(match.id, match.bracket, match.mode, match.ruleset, match.stageId, match.startedAt); + const insertParticipant = database.prepare(` + INSERT INTO arena_match_participants ( + match_id, actor_id, account_id, character_id, team, role, is_bot, disconnected, + rating_before, rating_delta, rating_after + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL) + `); + for (const participant of match.participants) { + const before = participant.isBot + ? clampArenaRating(participant.rating) + : getArenaRating(participant.accountId, participant.characterId, match.bracket).rating; + insertParticipant.run( + match.id, participant.actorId, participant.accountId ?? null, participant.characterId ?? null, + participant.team, participant.role, participant.isBot ? 1 : 0, participant.disconnected ? 1 : 0, before, + ); + } + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } + return match; + } + + function settleArenaMatch(matchId, winnerTeam) { + database.exec("BEGIN IMMEDIATE"); + try { + const match = database.prepare("SELECT * FROM arena_matches WHERE id = ?").get(matchId); + if (!match) throw new GameDatabaseError("Arena match not found.", 404, "arena_match_not_found"); + const rows = database.prepare("SELECT * FROM arena_match_participants WHERE match_id = ? ORDER BY actor_id").all(matchId); + if (match.status === "settled") { + database.exec("COMMIT"); + return { + matchId, + winnerTeam: match.winner_team == null ? null : Number(match.winner_team), + draw: match.winner_team == null, + ratingUpdates: rows.filter((row) => !row.is_bot).map((row) => ({ + characterId: row.character_id, bracket: match.bracket, before: Number(row.rating_before), + delta: Number(row.rating_delta), after: Number(row.rating_after), title: arenaRatingBand(row.rating_after).title, + })), + }; + } + if (match.status === "void") throw new GameDatabaseError("That arena match was voided.", 409, "arena_match_void"); + const normalizedWinner = winnerTeam === 0 || winnerTeam === 1 ? winnerTeam : null; + const teamRatings = [0, 1].map((team) => { + const teamRows = rows.filter((row) => Number(row.team) === team); + return Math.round(teamRows.reduce((sum, row) => sum + Number(row.rating_before ?? 0), 0) / Math.max(1, teamRows.length)); + }); + const updates = []; + for (const row of rows) { + if (row.is_bot || !row.account_id || !row.character_id) continue; + const team = Number(row.team); + const score = normalizedWinner == null ? 0.5 : normalizedWinner === team ? 1 : 0; + const update = calculateArenaRating({ + rating: Number(row.rating_before), opponentRating: teamRatings[team === 0 ? 1 : 0], score, + }); + database.prepare(` + UPDATE arena_match_participants SET rating_delta = ?, rating_after = ? + WHERE match_id = ? AND actor_id = ? AND rating_after IS NULL + `).run(update.delta, update.after, matchId, row.actor_id); + statements.upsertArenaRating.run(row.account_id, row.character_id, match.bracket, update.after, Date.now()); + updates.push({ + characterId: row.character_id, bracket: match.bracket, before: update.before, + delta: update.delta, after: update.after, title: arenaRatingBand(update.after).title, + }); + } + const settledAt = Date.now(); + database.prepare(` + UPDATE arena_matches SET status = 'settled', winner_team = ?, settled_at = ? WHERE id = ? AND status = 'active' + `).run(normalizedWinner, settledAt, matchId); + database.exec("COMMIT"); + return { matchId, winnerTeam: normalizedWinner, draw: normalizedWinner == null, ratingUpdates: updates }; + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } + } + + function voidArenaMatch(matchId) { + const settledAt = Date.now(); + const result = database.prepare(` + UPDATE arena_matches SET status = 'void', settled_at = ? WHERE id = ? AND status = 'active' + `).run(settledAt, matchId); + return { ok: true, changed: Number(result.changes) > 0 }; + } + return { databasePath, register, @@ -582,6 +912,15 @@ export function openGameDatabase(options = {}) { updateOnlineGroupMember, startOnlineGroupActivity, clearOnlineGroupActivity, + getArenaState, + putArenaLoadout, + deleteArenaLoadout, + arenaLoadoutForQueue, + selectedArenaLoadoutForQueue, + getArenaRating, + createArenaMatch, + settleArenaMatch, + voidArenaMatch, pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes), close: () => database.close(), }; diff --git a/server/server.mjs b/server/server.mjs index a1919a63..0cc2c51a 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -3,8 +3,10 @@ import { createServer } from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { GameDatabaseError, openGameDatabase } from "./database.mjs"; +import { createArenaService } from "./arena-service.mjs"; import { publicManastormAdminConfig } from "./manastorm-config.mjs"; import { createOnlineSessionManager } from "./online-session.mjs"; +import { upgradeArenaWebSocket } from "./websocket.mjs"; const MIME_TYPES = new Map([ [".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"], @@ -151,6 +153,7 @@ export function createGameServer(options = {}) { const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true"; const gameDatabase = options.database ?? openGameDatabase(options); const onlineSessions = createOnlineSessionManager(); + const arenaService = createArenaService({ database: gameDatabase }); const allowAuthRequest = createAuthLimiter(); gameDatabase.pruneExpiredSessions(); @@ -168,7 +171,7 @@ export function createGameServer(options = {}) { response.writeHead(204, { ...cors, "Access-Control-Allow-Headers": "Authorization, Content-Type", - "Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, OPTIONS", + "Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, DELETE, OPTIONS", "Access-Control-Max-Age": "86400", }); response.end(); @@ -264,6 +267,45 @@ export function createGameServer(options = {}) { json(response, 200, onlineSessions.sync(account, groupSnapshot, body)); return; } + if (url.pathname === "/api/arena/state" && request.method === "GET") { + const account = gameDatabase.authenticate(bearerToken(request)); + json(response, 200, arenaService.arenaState(account.id, url.searchParams.get("characterId"))); + return; + } + const arenaLoadoutMatch = /^\/api\/arena\/loadouts\/(\d+)$/.exec(url.pathname); + if (arenaLoadoutMatch && request.method === "PUT") { + const account = gameDatabase.authenticate(bearerToken(request)); + const body = await readJson(request, 64 * 1024); + json(response, 200, gameDatabase.putArenaLoadout(account.id, body.characterId, arenaLoadoutMatch[1], body)); + return; + } + if (arenaLoadoutMatch && request.method === "DELETE") { + const account = gameDatabase.authenticate(bearerToken(request)); + const body = await readJson(request, 16 * 1024); + json(response, 200, gameDatabase.deleteArenaLoadout(account.id, body.characterId, arenaLoadoutMatch[1])); + return; + } + if (url.pathname === "/api/arena/queue" && request.method === "POST") { + const account = gameDatabase.authenticate(bearerToken(request)); + const body = await readJson(request, 128 * 1024); + json(response, 201, arenaService.enqueue(account, body)); + return; + } + if (url.pathname === "/api/arena/queue" && request.method === "DELETE") { + const account = gameDatabase.authenticate(bearerToken(request)); + json(response, 200, arenaService.leaveQueue(account.id)); + return; + } + if (url.pathname === "/api/arena/ready" && request.method === "POST") { + const account = gameDatabase.authenticate(bearerToken(request)); + json(response, 200, arenaService.ready(account.id)); + return; + } + if (url.pathname === "/api/arena/socket-ticket" && request.method === "POST") { + const account = gameDatabase.authenticate(bearerToken(request)); + json(response, 201, arenaService.socketTicket(account.id)); + return; + } if (url.pathname === "/api/manastorm-config" && request.method === "GET") { const current = gameDatabase.getManastormConfig(); json(response, 200, { @@ -327,11 +369,18 @@ export function createGameServer(options = {}) { } }); + server.on("upgrade", (request, socket, head) => { + if (!upgradeArenaWebSocket(request, socket, head, arenaService)) { + socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); + } + }); + server.on("close", () => { onlineSessions.clear(); + arenaService.close(); gameDatabase.close(); }); - return { server, database: gameDatabase, staticDir, contentDir }; + return { server, database: gameDatabase, arenaService, staticDir, contentDir }; } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { diff --git a/server/server.test.mjs b/server/server.test.mjs index 54d0350f..1da48123 100644 --- a/server/server.test.mjs +++ b/server/server.test.mjs @@ -147,6 +147,69 @@ test("serves the game and authenticated cloud-save API", async () => { assert.equal(memberSession.players.length, 2); assert.equal(memberSession.events[0].targetActorId, "boss"); + const leftGroup = await fetch(`${origin}/api/online-group/leave`, { + method: "POST", + headers: { Authorization: `Bearer ${auth.token}` }, + }); + assert.equal(leftGroup.status, 200); + const arenaCharacter = { + id: "web-healer", name: "Mendara", classId: "priest", categoryId: "wow", + specializationId: "priest:discipline", raceId: "human", gender: "female", level: 80, + talentRanks: {}, actionBindings: {}, inventory: [], equipment: {}, + }; + const arenaSave = await fetch(`${origin}/api/cloud-save`, { + method: "PUT", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` }, + body: JSON.stringify({ data: { version: 1, characters: [arenaCharacter] } }), + }); + assert.equal(arenaSave.status, 200); + const loadout = await fetch(`${origin}/api/arena/loadouts/1`, { + method: "PUT", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` }, + body: JSON.stringify({ + characterId: arenaCharacter.id, name: "Discipline PvP", classId: "priest", + specializationId: "priest:discipline", role: "healer", ruleset: "wow", + talents: {}, actions: {}, selected: true, + }), + }); + assert.equal(loadout.status, 200); + assert.equal((await loadout.json()).level, 80); + const arenaState = await (await fetch(`${origin}/api/arena/state?characterId=${arenaCharacter.id}`, { + headers: { Authorization: `Bearer ${auth.token}` }, + })).json(); + assert.equal(arenaState.stages.length, 8); + assert.equal(arenaState.stages.every((stage) => stage.validation.status === "validated"), true); + + const socketTicket = await (await fetch(`${origin}/api/arena/socket-ticket`, { + method: "POST", + headers: { Authorization: `Bearer ${auth.token}` }, + })).json(); + const socket = new WebSocket(`${origin.replace("http:", "ws:")}${socketTicket.path}?ticket=${socketTicket.ticket}`); + const messages = []; + socket.addEventListener("message", (event) => messages.push(JSON.parse(String(event.data)))); + await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + }); + const arenaQueue = await fetch(`${origin}/api/arena/queue`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` }, + body: JSON.stringify({ characterId: arenaCharacter.id, loadoutSlot: 1, bracket: "2v2", mode: "rated-ai", ruleset: "wow" }), + }); + assert.equal(arenaQueue.status, 201); + assert.equal((await arenaQueue.json()).status, "ready-check"); + const arenaReady = await fetch(`${origin}/api/arena/ready`, { + method: "POST", + headers: { Authorization: `Bearer ${auth.token}` }, + }); + assert.equal(arenaReady.status, 200); + assert.equal((await arenaReady.json()).status, "matched"); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(messages.some((message) => message.type === "connected"), true); + assert.equal(messages.some((message) => message.type === "ready-check"), true); + assert.equal(messages.some((message) => message.type === "match-start"), true); + socket.close(); + const gmRegistration = await fetch(`${origin}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/server/websocket.mjs b/server/websocket.mjs new file mode 100644 index 00000000..0b5a57b9 --- /dev/null +++ b/server/websocket.mjs @@ -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; +} diff --git a/shared/arena-stages.json b/shared/arena-stages.json new file mode 100644 index 00000000..1a45594e --- /dev/null +++ b/shared/arena-stages.json @@ -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."] } + } + ] +} diff --git a/src/App.tsx b/src/App.tsx index 67b24277..72cf5a3f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,8 @@ const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module) const DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen }))); const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen }))); const ManastormSelectScreen = lazy(() => import("./ui/ManastormSelectScreen").then((module) => ({ default: module.ManastormSelectScreen }))); +const ArenaSelectScreen = lazy(() => import("./ui/ArenaSelectScreen").then((module) => ({ default: module.ArenaSelectScreen }))); +const ArenaRuntime = lazy(() => import("./ArenaRuntime")); const GmAdminScreen = lazy(() => import("./ui/GmAdminScreen").then((module) => ({ default: module.GmAdminScreen }))); function ShellTransition() { @@ -72,6 +74,8 @@ export default function App() { else if (phase === "class-hall") screen = }>; else if (phase === "dungeons") screen = }>; else if (phase === "manastorms") screen = }>; + else if (phase === "arenas") screen = }>; + else if (phase === "arena-game") screen = }>; else if (phase === "gm-admin") screen = }>; else if (phase === "game") screen = }>; const requiresRuntimeCatalog = !["login", "characters", "create-character"].includes(phase); diff --git a/src/ArenaRuntime.tsx b/src/ArenaRuntime.tsx new file mode 100644 index 00000000..79785005 --- /dev/null +++ b/src/ArenaRuntime.tsx @@ -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 ? : ; +} + +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 ; +} + +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 ( + + + + + + {stage.lineOfSight.map((object) => ( + + {object.shape === "cylinder" + ? + : } + + + ))} + + ); +} + +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 ( + + {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 ( + + {object.shape === "cylinder" + ? + : } + + + ); + })} + {fire?.state === "active" && [-31, 31].map((z) => ( + + + + + ))} + {pipes && (pipes.state === "warning" || pipes.state === "active") && stage.teamSpawns.map((spawn, index) => ( + + + + + ))} + {elevators && stage.teamSpawns.map((spawn, index) => { + const descent = elevators.state === "open" ? 1 : elevators.state === "moving" ? elevators.progress : 0; + return ( + + + + + ); + })} + + ); +} + +function ArenaActor({ actor, local, character, movement, stage }: { actor: ArenaActorSnapshot; local: boolean; character: ReturnType; movement: RefObject<[number, number]>; stage: ArenaStageManifest }) { + const group = useRef(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 ( + + {local && character ? ( + } /> + ) : ( + + + + + )} + + + ); +} + +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; movement: RefObject<[number, number]> }) { + return ( + + + + + + + + + {actors.map((actor) => )} + + + + ); +} + +function TeamFrames({ actors, team }: { actors: readonly ArenaActorSnapshot[]; team: 0 | 1 }) { + return ( +
+ {actors.filter((actor) => actor.team === team).map((actor) => ( + + ))} +
+ ); +} + +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(); + 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
HMEntering arenaWaiting for the authoritative match snapshot...
; + const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null; + return ( +
+
+
+ {stage.name}{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}Dampening {Math.round(snapshot.dampening * 100)}% +
+ + + {snapshot.phase === "preparation" &&
Gates sealed{seconds}Prepare your opening
} + + + {result && ( +
+

Match complete

+

{result.draw ? "Draw" : result.winnerTeam === localActor?.team ? "Victory" : "Defeat"}

+ {result.ratingUpdates.map((update) =>

{update.bracket}: {update.before} {update.delta >= 0 ? "+" : ""}{update.delta} → {update.after} / {update.title}

)} + {!result.ratingUpdates.length &&

Practice result — rating unchanged.

} + +
+ )} +
+ ); +} diff --git a/src/app/arenaClient.ts b/src/app/arenaClient.ts new file mode 100644 index 00000000..75925e52 --- /dev/null +++ b/src/app/arenaClient.ts @@ -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 { + return requestOnlineJson(`/api/arena/state?characterId=${encodeURIComponent(characterId)}`, {}, token(session)); +} + +export function putArenaLoadout( + session: PlayerSession, + characterId: string, + slot: number, + loadout: Omit & { selected?: boolean }, +): Promise { + 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 { + return requestOnlineJson("/api/arena/queue", { method: "POST", body: JSON.stringify(request) }, token(session)); +} + +export function leaveArenaQueue(session: PlayerSession): Promise { + return requestOnlineJson("/api/arena/queue", { method: "DELETE" }, token(session)); +} + +export function acceptArenaReadyCheck(session: PlayerSession): Promise { + 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 { + const response = await requestOnlineJson("/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((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; +} diff --git a/src/app/onlineAccountClient.ts b/src/app/onlineAccountClient.ts index 01c3945e..d5e7cb7a 100644 --- a/src/app/onlineAccountClient.ts +++ b/src/app/onlineAccountClient.ts @@ -40,7 +40,7 @@ let pendingCharacters: CharacterProfile[] | null = null; let saveTimer: ReturnType | null = null; let saveQueue = Promise.resolve(); -function apiOrigin(): string { +export function onlineApiOrigin(): string { const configured = String(import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, ""); if (configured) return configured; return Capacitor.isNativePlatform() ? NATIVE_API_ORIGIN : ""; @@ -53,7 +53,7 @@ export async function requestOnlineJson(pathname: string, init: RequestInit = if (token) headers.set("Authorization", `Bearer ${token}`); let response: Response; try { - response = await fetch(`${apiOrigin()}${pathname}`, { ...init, headers }); + response = await fetch(`${onlineApiOrigin()}${pathname}`, { ...init, headers }); } catch { throw new OnlineApiError("The online server is unavailable. Check your connection or play offline.", 0); } diff --git a/src/app/shellStore.ts b/src/app/shellStore.ts index 6d80dede..f0b4d7fc 100644 --- a/src/app/shellStore.ts +++ b/src/app/shellStore.ts @@ -86,6 +86,7 @@ export interface ShellState { openMainMenu: () => boolean; openDungeons: () => boolean; openManastorms: () => boolean; + openArenas: () => boolean; openGmAdmin: () => boolean; openClassHall: () => boolean; changeSelectedSpecialization: (specializationId: string, resetTalents: boolean) => boolean; @@ -333,6 +334,15 @@ export const useShellStore = create((set, get) => ({ set({ phase: "manastorms", activeCharacter: null, notice: "" }); return true; }, + openArenas: () => { + const { session, selectedCharacterId, characters } = get(); + if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) { + set({ phase: session ? "characters" : "login", notice: "Select or create a character first." }); + return false; + } + set({ phase: "arenas", activeCharacter: null, notice: "" }); + return true; + }, openGmAdmin: () => { const { session, selectedCharacterId, characters } = get(); if (!isGameMasterSession(session)) { diff --git a/src/app/types.ts b/src/app/types.ts index c801fe68..9bbe6b9a 100644 --- a/src/app/types.ts +++ b/src/app/types.ts @@ -5,7 +5,7 @@ import type { EquipmentAssignments } from "../game/equipment"; import type { GameplaySettings } from "../game/combatStore"; import type { ManastormProgress } from "../game/manastormProgress"; -export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "gm-admin" | "game"; +export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "arenas" | "arena-game" | "gm-admin" | "game"; export type SessionKind = "account" | "offline"; export type AccountRole = "gm"; diff --git a/src/game/arenaPractice.ts b/src/game/arenaPractice.ts new file mode 100644 index 00000000..622ed142 --- /dev/null +++ b/src/game/arenaPractice.ts @@ -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(); + }, + }; +} diff --git a/src/game/arenaRules.test.ts b/src/game/arenaRules.test.ts new file mode 100644 index 00000000..52f64a32 --- /dev/null +++ b/src/game/arenaRules.test.ts @@ -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); + }); +}); diff --git a/src/game/arenaRules.ts b/src/game/arenaRules.ts new file mode 100644 index 00000000..002cf2ee --- /dev/null +++ b/src/game/arenaRules.ts @@ -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> = 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 }; +} diff --git a/src/game/arenaStages.ts b/src/game/arenaStages.ts new file mode 100644 index 00000000..17a8c91a --- /dev/null +++ b/src/game/arenaStages.ts @@ -0,0 +1,25 @@ +import catalog from "../../shared/arena-stages.json"; +import type { ArenaStageManifest } from "./arenaTypes"; + +function normalizeStage(stage: Omit): 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)), +); + +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; +} diff --git a/src/game/arenaStore.ts b/src/game/arenaStore.ts new file mode 100644 index 00000000..994c58fd --- /dev/null +++ b/src/game/arenaStore.ts @@ -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 | null; + sequence: number; + configure: (patch: Partial>) => void; + load: (session: PlayerSession | null, character: CharacterProfile) => Promise; + saveLoadout: (session: PlayerSession, character: CharacterProfile, input: { slot: number; name: string; specializationId: string }) => Promise; + deleteLoadout: (session: PlayerSession, characterId: string, slot: number) => Promise; + queueMatch: (session: PlayerSession | null, character: CharacterProfile) => Promise; + ready: (session: PlayerSession) => Promise; + leave: (session: PlayerSession | null) => Promise; + intent: (intent: Omit) => 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((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; +} diff --git a/src/game/arenaTypes.ts b/src/game/arenaTypes.ts new file mode 100644 index 00000000..7f3eac4d --- /dev/null +++ b/src/game/arenaTypes.ts @@ -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; + actions: Record; + 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; + 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[]; +} diff --git a/src/styles.css b/src/styles.css index 7d306af3..77d4f10a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3094,3 +3094,355 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); } @media (max-height: 560px) and (min-width: 601px) { .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; } +} diff --git a/src/ui/ArenaSelectScreen.tsx b/src/ui/ArenaSelectScreen.tsx new file mode 100644 index 00000000..0311fc29 --- /dev/null +++ b/src/ui/ArenaSelectScreen.tsx @@ -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 ( + +
+
+Arena Command
+

{character.name}{arena.bracket} / {rating ? `${rating.rating} ${rating.title}` : "Practice"}

+ +
+ + + +
+
+

Eight battlegrounds

{stageHeading}

validated
+
+ + {ARENA_STAGES.map((stage, index) => ( + + ))} +
+

{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]}

+
+ +
+ {arena.mode === "offline-practice" ? ( + <> +

Opponent calibration

{arena.botDifficulty} / {profile.title}

+ arena.configure({ botDifficulty: Number(event.target.value) })} /> +
+
Reaction
{profile.reactionMinSeconds.toFixed(2)}–{profile.reactionMaxSeconds.toFixed(2)}s
+
Healer kicks
{Math.round(profile.healerInterruptChance * 100)}%
+
Minimum error
{Math.round(profile.minimumErrorChance * 100)}%
+
+ + ) : ( + <> +

Level 80 PvP profile

{selectedLoadout?.name ?? "Automatic first loadout"}

+ {!online &&

Sign in to use rated queues. Practice remains fully offline.

} + {online && ( +
+ + + + +
+ )} +

Gear and prestige are replaced by versioned arena templates. Cosmetics remain visible.

+ + )} + + {arena.error &&

{arena.error}

} +
+
+ + + )} + bottom={} + /> + ); +} diff --git a/src/ui/MainMenuScreen.tsx b/src/ui/MainMenuScreen.tsx index 624b534e..9a022537 100644 --- a/src/ui/MainMenuScreen.tsx +++ b/src/ui/MainMenuScreen.tsx @@ -27,11 +27,11 @@ const GAME_MODES = [ available: true, }, { - id: "roguelike-rpg", + id: "arenas", number: "03", - name: "Roguelike RPG", - description: "A new mode is taking shape.", - available: false, + name: "Arenas", + description: "Rated 2v2 and 3v3 combat against players or rating-matched opponents.", + available: true, }, { id: "trials", @@ -69,6 +69,7 @@ export function MainMenuScreen() { const rosterCharacter = useShellStore(selectedCharacter); const openDungeons = useShellStore((state) => state.openDungeons); const openManastorms = useShellStore((state) => state.openManastorms); + const openArenas = useShellStore((state) => state.openArenas); const openClassHall = useShellStore((state) => state.openClassHall); const returnToCharacters = useShellStore((state) => state.returnToCharacters); const notice = useShellStore((state) => state.notice); @@ -89,6 +90,11 @@ export function MainMenuScreen() { run: openManastorms, neighbors: { left: "mode-dungeons", down: "back-to-roster" }, }, + { + id: "mode-arenas", + run: openArenas, + neighbors: { left: "mode-manastorms", down: "back-to-roster" }, + }, { id: "mode-downloads", run: () => setContentManagerOpen(true), @@ -101,7 +107,7 @@ export function MainMenuScreen() { }, ...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []), ...(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, { initialId: "mode-dungeons", onBack: returnToCharacters, @@ -197,6 +203,8 @@ export function MainMenuScreen() { type="button" onClick={mode.id === "manastorms" ? openManastorms + : mode.id === "arenas" + ? openArenas : mode.id === "downloads" ? () => setContentManagerOpen(true) : openDungeons}