Compare commits

...
4 Commits
Author SHA1 Message Date
phenom 699fc858c9 Release Healer Man 0.1.20 2026-09-06 14:38:59 -04:00
phenom d9bebb4e81 Release Healer Man 0.1.19 2026-09-06 11:06:03 -04:00
phenom eaddad4134 Release Healer Man 0.1.18 2026-09-05 22:15:48 -04:00
phenom fc8a7a722a Release Healer Man 0.1.17 2026-09-05 21:27:30 -04:00
48 changed files with 1209 additions and 388 deletions

No files matched your search

+121 -105
View File
@@ -1,17 +1,17 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"status": "green", "status": "green",
"scope": "HealerMan WoW five-player campaign", "scope": "HealerMan WoW five-player combat and animation coverage",
"summary": { "summary": {
"dungeons": 54, "dungeons": 54,
"mobs": 1031, "mobs": 1031,
"bosses": 253, "bosses": 253,
"attacks": 2953, "attacks": 3765,
"specialAttacks": 1669, "specialAttacks": 2481,
"nativeModels": 1266, "nativeModels": 1266,
"proceduralModels": 18, "proceduralModels": 18,
"uniqueNativeModels": 1166, "uniqueNativeModels": 1166,
"nativeStateFallbacks": 16 "nativeStateFallbacks": 18
}, },
"documentedExceptions": [ "documentedExceptions": [
{ {
@@ -47,6 +47,14 @@
], ],
"renderer": "OriginalCreatureModel combat fallback" "renderer": "OriginalCreatureModel combat fallback"
}, },
{
"url": "/assets/game/dungeons/dire-maul/creatures/display-14430-animated.glb",
"state": "move",
"users": [
"dire-maul:entry-14386"
],
"renderer": "AnimatedMobBody locomotion fallback"
},
{ {
"url": "/assets/game/dungeons/dire-maul/creatures/display-14430-animated.glb", "url": "/assets/game/dungeons/dire-maul/creatures/display-14430-animated.glb",
"state": "attack", "state": "attack",
@@ -56,7 +64,15 @@
"renderer": "OriginalCreatureModel combat fallback" "renderer": "OriginalCreatureModel combat fallback"
}, },
{ {
"url": "/assets/game/creatures/shared/2fbb8d2226a7fdf3e9a7af13568f6bb46cb1b27afc008732832b9fd42c2e510e.glb", "url": "/assets/game/dungeons/dire-maul/creatures/display-14430-animated.glb",
"state": "wound",
"users": [
"dire-maul:entry-14386"
],
"renderer": "OriginalCreatureModel combat fallback"
},
{
"url": "/assets/game/creatures/shared/52c8b3aea63f21f8e98ad84eaf601f2918d1df17a05525d41f5b3e17da53c8ff.glb",
"state": "wound", "state": "wound",
"users": [ "users": [
"steamvault:entry-21694", "steamvault:entry-21694",
@@ -170,8 +186,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 24, "mobs": 24,
"bosses": 7, "bosses": 7,
"attacks": 58, "attacks": 65,
"specialAttacks": 27, "specialAttacks": 34,
"nativeModels": 31, "nativeModels": 31,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -182,8 +198,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 6, "mobs": 6,
"bosses": 5, "bosses": 5,
"attacks": 32, "attacks": 42,
"specialAttacks": 21, "specialAttacks": 31,
"nativeModels": 11, "nativeModels": 11,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -194,8 +210,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 15, "mobs": 15,
"bosses": 7, "bosses": 7,
"attacks": 48, "attacks": 55,
"specialAttacks": 26, "specialAttacks": 33,
"nativeModels": 22, "nativeModels": 22,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -218,8 +234,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 31, "mobs": 31,
"bosses": 7, "bosses": 7,
"attacks": 78, "attacks": 87,
"specialAttacks": 40, "specialAttacks": 49,
"nativeModels": 38, "nativeModels": 38,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -230,8 +246,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 21, "mobs": 21,
"bosses": 6, "bosses": 6,
"attacks": 58, "attacks": 65,
"specialAttacks": 31, "specialAttacks": 38,
"nativeModels": 27, "nativeModels": 27,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -242,8 +258,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 31, "mobs": 31,
"bosses": 7, "bosses": 7,
"attacks": 85, "attacks": 104,
"specialAttacks": 47, "specialAttacks": 66,
"nativeModels": 38, "nativeModels": 38,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -254,8 +270,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 27, "mobs": 27,
"bosses": 4, "bosses": 4,
"attacks": 68, "attacks": 82,
"specialAttacks": 37, "specialAttacks": 51,
"nativeModels": 31, "nativeModels": 31,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -266,8 +282,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 29, "mobs": 29,
"bosses": 4, "bosses": 4,
"attacks": 86, "attacks": 93,
"specialAttacks": 53, "specialAttacks": 60,
"nativeModels": 33, "nativeModels": 33,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -278,8 +294,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 19, "mobs": 19,
"bosses": 6, "bosses": 6,
"attacks": 55, "attacks": 63,
"specialAttacks": 30, "specialAttacks": 38,
"nativeModels": 25, "nativeModels": 25,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -290,8 +306,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 32, "mobs": 32,
"bosses": 5, "bosses": 5,
"attacks": 89, "attacks": 107,
"specialAttacks": 52, "specialAttacks": 70,
"nativeModels": 37, "nativeModels": 37,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -302,8 +318,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 16, "mobs": 16,
"bosses": 5, "bosses": 5,
"attacks": 53, "attacks": 69,
"specialAttacks": 32, "specialAttacks": 48,
"nativeModels": 21, "nativeModels": 21,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -314,8 +330,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 63, "mobs": 63,
"bosses": 15, "bosses": 15,
"attacks": 210, "attacks": 273,
"specialAttacks": 132, "specialAttacks": 195,
"nativeModels": 78, "nativeModels": 78,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -326,8 +342,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 48, "mobs": 48,
"bosses": 13, "bosses": 13,
"attacks": 145, "attacks": 182,
"specialAttacks": 84, "specialAttacks": 121,
"nativeModels": 61, "nativeModels": 61,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -338,8 +354,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 30, "mobs": 30,
"bosses": 8, "bosses": 8,
"attacks": 115, "attacks": 150,
"specialAttacks": 77, "specialAttacks": 112,
"nativeModels": 38, "nativeModels": 38,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -350,8 +366,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 41, "mobs": 41,
"bosses": 9, "bosses": 9,
"attacks": 131, "attacks": 176,
"specialAttacks": 81, "specialAttacks": 126,
"nativeModels": 50, "nativeModels": 50,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -362,8 +378,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 25, "mobs": 25,
"bosses": 8, "bosses": 8,
"attacks": 83, "attacks": 95,
"specialAttacks": 50, "specialAttacks": 62,
"nativeModels": 33, "nativeModels": 33,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -374,8 +390,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 7, "mobs": 7,
"bosses": 4, "bosses": 4,
"attacks": 21, "attacks": 23,
"specialAttacks": 10, "specialAttacks": 12,
"nativeModels": 11, "nativeModels": 11,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -386,8 +402,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 63, "mobs": 63,
"bosses": 12, "bosses": 12,
"attacks": 184, "attacks": 242,
"specialAttacks": 109, "specialAttacks": 167,
"nativeModels": 75, "nativeModels": 75,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -398,8 +414,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 29, "mobs": 29,
"bosses": 3, "bosses": 3,
"attacks": 74, "attacks": 92,
"specialAttacks": 42, "specialAttacks": 60,
"nativeModels": 32, "nativeModels": 32,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -410,8 +426,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 14, "mobs": 14,
"bosses": 3, "bosses": 3,
"attacks": 44, "attacks": 53,
"specialAttacks": 27, "specialAttacks": 36,
"nativeModels": 17, "nativeModels": 17,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -422,8 +438,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 10, "mobs": 10,
"bosses": 2, "bosses": 2,
"attacks": 36, "attacks": 41,
"specialAttacks": 24, "specialAttacks": 29,
"nativeModels": 12, "nativeModels": 12,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -434,8 +450,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 14, "mobs": 14,
"bosses": 3, "bosses": 3,
"attacks": 50, "attacks": 63,
"specialAttacks": 33, "specialAttacks": 46,
"nativeModels": 17, "nativeModels": 17,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -446,8 +462,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 18, "mobs": 18,
"bosses": 4, "bosses": 4,
"attacks": 59, "attacks": 80,
"specialAttacks": 37, "specialAttacks": 58,
"nativeModels": 22, "nativeModels": 22,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -458,8 +474,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 18, "mobs": 18,
"bosses": 3, "bosses": 3,
"attacks": 53, "attacks": 60,
"specialAttacks": 32, "specialAttacks": 39,
"nativeModels": 21, "nativeModels": 21,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -470,8 +486,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 25, "mobs": 25,
"bosses": 4, "bosses": 4,
"attacks": 73, "attacks": 105,
"specialAttacks": 44, "specialAttacks": 76,
"nativeModels": 29, "nativeModels": 29,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -482,8 +498,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 24, "mobs": 24,
"bosses": 5, "bosses": 5,
"attacks": 67, "attacks": 85,
"specialAttacks": 38, "specialAttacks": 56,
"nativeModels": 29, "nativeModels": 29,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -494,8 +510,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 14, "mobs": 14,
"bosses": 3, "bosses": 3,
"attacks": 45, "attacks": 69,
"specialAttacks": 28, "specialAttacks": 52,
"nativeModels": 17, "nativeModels": 17,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -506,8 +522,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 18, "mobs": 18,
"bosses": 4, "bosses": 4,
"attacks": 46, "attacks": 67,
"specialAttacks": 24, "specialAttacks": 45,
"nativeModels": 22, "nativeModels": 22,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -518,8 +534,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 14, "mobs": 14,
"bosses": 2, "bosses": 2,
"attacks": 39, "attacks": 50,
"specialAttacks": 23, "specialAttacks": 34,
"nativeModels": 16, "nativeModels": 16,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -530,8 +546,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 11, "mobs": 11,
"bosses": 3, "bosses": 3,
"attacks": 33, "attacks": 46,
"specialAttacks": 19, "specialAttacks": 32,
"nativeModels": 14, "nativeModels": 14,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -542,8 +558,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 12, "mobs": 12,
"bosses": 2, "bosses": 2,
"attacks": 30, "attacks": 44,
"specialAttacks": 16, "specialAttacks": 30,
"nativeModels": 14, "nativeModels": 14,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -554,8 +570,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 15, "mobs": 15,
"bosses": 3, "bosses": 3,
"attacks": 33, "attacks": 57,
"specialAttacks": 15, "specialAttacks": 39,
"nativeModels": 18, "nativeModels": 18,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -566,8 +582,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 4, "mobs": 4,
"bosses": 3, "bosses": 3,
"attacks": 20, "attacks": 25,
"specialAttacks": 13, "specialAttacks": 18,
"nativeModels": 7, "nativeModels": 7,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -578,8 +594,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 26, "mobs": 26,
"bosses": 3, "bosses": 3,
"attacks": 45, "attacks": 53,
"specialAttacks": 16, "specialAttacks": 24,
"nativeModels": 29, "nativeModels": 29,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -590,8 +606,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 19, "mobs": 19,
"bosses": 3, "bosses": 3,
"attacks": 35, "attacks": 44,
"specialAttacks": 13, "specialAttacks": 22,
"nativeModels": 22, "nativeModels": 22,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -602,8 +618,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 13, "mobs": 13,
"bosses": 4, "bosses": 4,
"attacks": 32, "attacks": 44,
"specialAttacks": 15, "specialAttacks": 27,
"nativeModels": 17, "nativeModels": 17,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -614,8 +630,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 21, "mobs": 21,
"bosses": 5, "bosses": 5,
"attacks": 50, "attacks": 61,
"specialAttacks": 24, "specialAttacks": 35,
"nativeModels": 26, "nativeModels": 26,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -626,8 +642,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 8, "mobs": 8,
"bosses": 4, "bosses": 4,
"attacks": 27, "attacks": 33,
"specialAttacks": 15, "specialAttacks": 21,
"nativeModels": 12, "nativeModels": 12,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -638,8 +654,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 16, "mobs": 16,
"bosses": 4, "bosses": 4,
"attacks": 41, "attacks": 44,
"specialAttacks": 21, "specialAttacks": 24,
"nativeModels": 20, "nativeModels": 20,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -650,8 +666,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 13, "mobs": 13,
"bosses": 4, "bosses": 4,
"attacks": 32, "attacks": 42,
"specialAttacks": 15, "specialAttacks": 25,
"nativeModels": 16, "nativeModels": 16,
"proceduralModels": 1, "proceduralModels": 1,
"status": "green" "status": "green"
@@ -662,8 +678,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 18, "mobs": 18,
"bosses": 4, "bosses": 4,
"attacks": 38, "attacks": 45,
"specialAttacks": 16, "specialAttacks": 23,
"nativeModels": 22, "nativeModels": 22,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -674,8 +690,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 10, "mobs": 10,
"bosses": 3, "bosses": 3,
"attacks": 25, "attacks": 30,
"specialAttacks": 12, "specialAttacks": 17,
"nativeModels": 13, "nativeModels": 13,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -686,8 +702,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 16, "mobs": 16,
"bosses": 4, "bosses": 4,
"attacks": 35, "attacks": 49,
"specialAttacks": 15, "specialAttacks": 29,
"nativeModels": 20, "nativeModels": 20,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -698,8 +714,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 15, "mobs": 15,
"bosses": 5, "bosses": 5,
"attacks": 43, "attacks": 53,
"specialAttacks": 23, "specialAttacks": 33,
"nativeModels": 20, "nativeModels": 20,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -710,8 +726,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 2, "mobs": 2,
"bosses": 3, "bosses": 3,
"attacks": 19, "attacks": 24,
"specialAttacks": 14, "specialAttacks": 19,
"nativeModels": 5, "nativeModels": 5,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -722,8 +738,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 19, "mobs": 19,
"bosses": 5, "bosses": 5,
"attacks": 43, "attacks": 60,
"specialAttacks": 19, "specialAttacks": 36,
"nativeModels": 24, "nativeModels": 24,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -734,8 +750,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 11, "mobs": 11,
"bosses": 2, "bosses": 2,
"attacks": 21, "attacks": 23,
"specialAttacks": 8, "specialAttacks": 10,
"nativeModels": 13, "nativeModels": 13,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -746,8 +762,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 0, "mobs": 0,
"bosses": 5, "bosses": 5,
"attacks": 22, "attacks": 88,
"specialAttacks": 17, "specialAttacks": 83,
"nativeModels": 5, "nativeModels": 5,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -758,8 +774,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 27, "mobs": 27,
"bosses": 3, "bosses": 3,
"attacks": 43, "attacks": 49,
"specialAttacks": 13, "specialAttacks": 19,
"nativeModels": 30, "nativeModels": 30,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
@@ -770,8 +786,8 @@
"source": "azerothcore", "source": "azerothcore",
"mobs": 8, "mobs": 8,
"bosses": 2, "bosses": 2,
"attacks": 26, "attacks": 38,
"specialAttacks": 16, "specialAttacks": 28,
"nativeModels": 10, "nativeModels": 10,
"proceduralModels": 0, "proceduralModels": 0,
"status": "green" "status": "green"
+55 -53
View File
@@ -1,64 +1,66 @@
# WoW dungeon content report # WoW dungeon combat and animation report
Status: **green** Status: **green**
Coverage: 54 dungeons, 1031 mobs, 253 bosses, 2953 executable attacks, and 1669 special attacks/mechanics. Scope: executable combat populations and creature animation bindings. This report does not certify instance scripts, doors, interactables, escorts, waves, vehicles, or navigation.
Animation paths: 1266 native model bindings and 18 procedural bindings across 1166 unique native GLBs; 16 missing native states use the shared procedural fallback. Coverage: 54 dungeons, 1031 mobs, 253 bosses, 3765 executable attacks, and 2481 special attacks/mechanics.
Animation paths: 1266 native model bindings and 18 procedural bindings across 1166 unique native GLBs; 18 missing native states use the shared procedural fallback.
| Dungeon | Mobs | Bosses | Attacks | Specials | Native | Procedural | Status | | Dungeon | Mobs | Bosses | Attacks | Specials | Native | Procedural | Status |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| Shadowfang Keep | 24 | 7 | 58 | 27 | 31 | 0 | green | | Shadowfang Keep | 24 | 7 | 65 | 34 | 31 | 0 | green |
| Stormwind Stockade | 6 | 5 | 32 | 21 | 11 | 0 | green | | Stormwind Stockade | 6 | 5 | 42 | 31 | 11 | 0 | green |
| The Deadmines | 15 | 7 | 48 | 26 | 22 | 0 | green | | The Deadmines | 15 | 7 | 55 | 33 | 22 | 0 | green |
| Wailing Caverns | 12 | 8 | 51 | 31 | 15 | 5 | green | | Wailing Caverns | 12 | 8 | 51 | 31 | 15 | 5 | green |
| Razorfen Kraul | 31 | 7 | 78 | 40 | 38 | 0 | green | | Razorfen Kraul | 31 | 7 | 87 | 49 | 38 | 0 | green |
| Blackfathom Deeps | 21 | 6 | 58 | 31 | 27 | 0 | green | | Blackfathom Deeps | 21 | 6 | 65 | 38 | 27 | 0 | green |
| Uldaman | 31 | 7 | 85 | 47 | 38 | 0 | green | | Uldaman | 31 | 7 | 104 | 66 | 38 | 0 | green |
| Gnomeregan | 27 | 4 | 68 | 37 | 31 | 0 | green | | Gnomeregan | 27 | 4 | 82 | 51 | 31 | 0 | green |
| Sunken Temple | 29 | 4 | 86 | 53 | 33 | 0 | green | | Sunken Temple | 29 | 4 | 93 | 60 | 33 | 0 | green |
| Razorfen Downs | 19 | 6 | 55 | 30 | 25 | 0 | green | | Razorfen Downs | 19 | 6 | 63 | 38 | 25 | 0 | green |
| Scarlet Monastery | 32 | 5 | 89 | 52 | 37 | 0 | green | | Scarlet Monastery | 32 | 5 | 107 | 70 | 37 | 0 | green |
| Zul'Farrak | 16 | 5 | 53 | 32 | 21 | 0 | green | | Zul'Farrak | 16 | 5 | 69 | 48 | 21 | 0 | green |
| Blackrock Spire | 63 | 15 | 210 | 132 | 78 | 0 | green | | Blackrock Spire | 63 | 15 | 273 | 195 | 78 | 0 | green |
| Blackrock Depths | 48 | 13 | 145 | 84 | 61 | 0 | green | | Blackrock Depths | 48 | 13 | 182 | 121 | 61 | 0 | green |
| Scholomance | 30 | 8 | 115 | 77 | 38 | 0 | green | | Scholomance | 30 | 8 | 150 | 112 | 38 | 0 | green |
| Stratholme | 41 | 9 | 131 | 81 | 50 | 0 | green | | Stratholme | 41 | 9 | 176 | 126 | 50 | 0 | green |
| Maraudon | 25 | 8 | 83 | 50 | 33 | 0 | green | | Maraudon | 25 | 8 | 95 | 62 | 33 | 0 | green |
| Ragefire Chasm | 7 | 4 | 21 | 10 | 11 | 0 | green | | Ragefire Chasm | 7 | 4 | 23 | 12 | 11 | 0 | green |
| Dire Maul | 63 | 12 | 184 | 109 | 75 | 0 | green | | Dire Maul | 63 | 12 | 242 | 167 | 75 | 0 | green |
| Hellfire Citadel: The Shattered Halls | 29 | 3 | 74 | 42 | 32 | 0 | green | | Hellfire Citadel: The Shattered Halls | 29 | 3 | 92 | 60 | 32 | 0 | green |
| Hellfire Citadel: The Blood Furnace | 14 | 3 | 44 | 27 | 17 | 0 | green | | Hellfire Citadel: The Blood Furnace | 14 | 3 | 53 | 36 | 17 | 0 | green |
| Hellfire Citadel: Ramparts | 10 | 2 | 36 | 24 | 12 | 0 | green | | Hellfire Citadel: Ramparts | 10 | 2 | 41 | 29 | 12 | 0 | green |
| Coilfang: The Steamvault | 14 | 3 | 50 | 33 | 17 | 0 | green | | Coilfang: The Steamvault | 14 | 3 | 63 | 46 | 17 | 0 | green |
| Coilfang: The Underbog | 18 | 4 | 59 | 37 | 22 | 0 | green | | Coilfang: The Underbog | 18 | 4 | 80 | 58 | 22 | 0 | green |
| Coilfang: The Slave Pens | 18 | 3 | 53 | 32 | 21 | 0 | green | | Coilfang: The Slave Pens | 18 | 3 | 60 | 39 | 21 | 0 | green |
| Tempest Keep: The Arcatraz | 25 | 4 | 73 | 44 | 29 | 0 | green | | Tempest Keep: The Arcatraz | 25 | 4 | 105 | 76 | 29 | 0 | green |
| Tempest Keep: The Botanica | 24 | 5 | 67 | 38 | 29 | 0 | green | | Tempest Keep: The Botanica | 24 | 5 | 85 | 56 | 29 | 0 | green |
| Tempest Keep: The Mechanar | 14 | 3 | 45 | 28 | 17 | 0 | green | | Tempest Keep: The Mechanar | 14 | 3 | 69 | 52 | 17 | 0 | green |
| Auchindoun: Shadow Labyrinth | 18 | 4 | 46 | 24 | 22 | 0 | green | | Auchindoun: Shadow Labyrinth | 18 | 4 | 67 | 45 | 22 | 0 | green |
| Auchindoun: Sethekk Halls | 14 | 2 | 39 | 23 | 16 | 0 | green | | Auchindoun: Sethekk Halls | 14 | 2 | 50 | 34 | 16 | 0 | green |
| Auchindoun: Mana-Tombs | 11 | 3 | 33 | 19 | 14 | 0 | green | | Auchindoun: Mana-Tombs | 11 | 3 | 46 | 32 | 14 | 0 | green |
| Auchindoun: Auchenai Crypts | 12 | 2 | 30 | 16 | 14 | 0 | green | | Auchindoun: Auchenai Crypts | 12 | 2 | 44 | 30 | 14 | 0 | green |
| Magister's Terrace | 15 | 3 | 33 | 15 | 18 | 0 | green | | Magister's Terrace | 15 | 3 | 57 | 39 | 18 | 0 | green |
| The Black Morass | 4 | 3 | 20 | 13 | 7 | 0 | green | | The Black Morass | 4 | 3 | 25 | 18 | 7 | 0 | green |
| The Escape From Durnholde | 26 | 3 | 45 | 16 | 29 | 0 | green | | The Escape From Durnholde | 26 | 3 | 53 | 24 | 29 | 0 | green |
| Utgarde Keep | 19 | 3 | 35 | 13 | 22 | 0 | green | | Utgarde Keep | 19 | 3 | 44 | 22 | 22 | 0 | green |
| Utgarde Pinnacle | 13 | 4 | 32 | 15 | 17 | 0 | green | | Utgarde Pinnacle | 13 | 4 | 44 | 27 | 17 | 0 | green |
| The Nexus | 21 | 5 | 50 | 24 | 26 | 0 | green | | The Nexus | 21 | 5 | 61 | 35 | 26 | 0 | green |
| The Oculus | 8 | 4 | 27 | 15 | 12 | 0 | green | | The Oculus | 8 | 4 | 33 | 21 | 12 | 0 | green |
| The Culling of Stratholme | 16 | 4 | 41 | 21 | 20 | 0 | green | | The Culling of Stratholme | 16 | 4 | 44 | 24 | 20 | 0 | green |
| Halls of Stone | 13 | 4 | 32 | 15 | 16 | 1 | green | | Halls of Stone | 13 | 4 | 42 | 25 | 16 | 1 | green |
| Drak'Tharon Keep | 18 | 4 | 38 | 16 | 22 | 0 | green | | Drak'Tharon Keep | 18 | 4 | 45 | 23 | 22 | 0 | green |
| Azjol-Nerub | 10 | 3 | 25 | 12 | 13 | 0 | green | | Azjol-Nerub | 10 | 3 | 30 | 17 | 13 | 0 | green |
| Halls of Lightning | 16 | 4 | 35 | 15 | 20 | 0 | green | | Halls of Lightning | 16 | 4 | 49 | 29 | 20 | 0 | green |
| Gundrak | 15 | 5 | 43 | 23 | 20 | 0 | green | | Gundrak | 15 | 5 | 53 | 33 | 20 | 0 | green |
| Violet Hold | 2 | 3 | 19 | 14 | 5 | 0 | green | | Violet Hold | 2 | 3 | 24 | 19 | 5 | 0 | green |
| Ahn'kahet: The Old Kingdom | 19 | 5 | 43 | 19 | 24 | 0 | green | | Ahn'kahet: The Old Kingdom | 19 | 5 | 60 | 36 | 24 | 0 | green |
| The Forge of Souls | 11 | 2 | 21 | 8 | 13 | 0 | green | | The Forge of Souls | 11 | 2 | 23 | 10 | 13 | 0 | green |
| Trial of the Champion | 0 | 5 | 22 | 17 | 5 | 0 | green | | Trial of the Champion | 0 | 5 | 88 | 83 | 5 | 0 | green |
| Pit of Saron | 27 | 3 | 43 | 13 | 30 | 0 | green | | Pit of Saron | 27 | 3 | 49 | 19 | 30 | 0 | green |
| Halls of Reflection | 8 | 2 | 26 | 16 | 10 | 0 | green | | Halls of Reflection | 8 | 2 | 38 | 28 | 10 | 0 | green |
| Tinkertech Showdown | 3 | 1 | 8 | 4 | 0 | 4 | green | | Tinkertech Showdown | 3 | 1 | 8 | 4 | 0 | 4 | green |
| Frozen Reach | 3 | 1 | 8 | 4 | 0 | 4 | green | | Frozen Reach | 3 | 1 | 8 | 4 | 0 | 4 | green |
| Forgotten Mine | 3 | 1 | 8 | 4 | 0 | 4 | green | | Forgotten Mine | 3 | 1 | 8 | 4 | 0 | 4 | green |
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.16", "version": "0.1.20",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.16", "version": "0.1.20",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.16", "version": "0.1.20",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.16", "versionName": "0.1.20",
"versionCode": 1016 "versionCode": 1020
} }
@@ -32,6 +32,10 @@ const sources = [
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"), directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
logicalPrefix: "assets/creatures/wailing-caverns", logicalPrefix: "assets/creatures/wailing-caverns",
}, },
{
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
logicalPrefix: "assets/game/arenas",
},
{ {
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"), directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
logicalPrefix: "assets/game/dungeons/wailing-caverns", logicalPrefix: "assets/game/dungeons/wailing-caverns",
@@ -33,6 +33,10 @@ const sources = [
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"), directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
logicalPrefix: "assets/creatures/wailing-caverns", logicalPrefix: "assets/creatures/wailing-caverns",
}, },
{
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
logicalPrefix: "assets/game/arenas",
},
{ {
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"), directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
logicalPrefix: "assets/game/dungeons/wailing-caverns", logicalPrefix: "assets/game/dungeons/wailing-caverns",
+3
View File
@@ -20,6 +20,7 @@ export const BUNDLED_ANDROID_PACKS = Object.freeze([
"core:equipment", "core:equipment",
"core:character-basic", "core:character-basic",
"core:runtime", "core:runtime",
"core:arenas",
"dungeon:wailing-caverns", "dungeon:wailing-caverns",
]); ]);
@@ -79,6 +80,7 @@ function packLabel(packId) {
if (packId === "core:equipment") return "Starter Equipment"; if (packId === "core:equipment") return "Starter Equipment";
if (packId === "core:character-basic") return "Basic Human Character Model"; if (packId === "core:character-basic") return "Basic Human Character Model";
if (packId === "core:runtime") return "Game Runtime"; if (packId === "core:runtime") return "Game Runtime";
if (packId === "core:arenas") return "Rated Arenas";
if (packId === "core:combat-media") return "Original Spell Effects & Sounds"; if (packId === "core:combat-media") return "Original Spell Effects & Sounds";
return name; return name;
} }
@@ -105,6 +107,7 @@ export function contentPackForPath(logicalPath) {
if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) { if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) {
return `dungeon:${segments[3]}`; return `dungeon:${segments[3]}`;
} }
if (segments[1] === "game" && segments[2] === "arenas") return "core:arenas";
if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) { if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) {
return `manastorm:${segments[3]}`; return `manastorm:${segments[3]}`;
} }
+2
View File
@@ -20,6 +20,8 @@ test("assigns stable player-facing packs from logical asset paths", () => {
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_01.webp"), "characters:wow335a-v1"); assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_01.webp"), "characters:wow335a-v1");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/orc/male/rig.glb"), "characters:wow335a-v1"); assert.equal(contentPackForPath("assets/characters/wow335a-v1/orc/male/rig.glb"), "characters:wow335a-v1");
assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1"); assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1");
assert.equal(contentPackForPath("assets/game/arenas/nagrand-arena/visual.glb"), "core:arenas");
assert.equal(BUNDLED_ANDROID_PACKS.includes("core:arenas"), true);
assert.equal(contentPackForPath("assets/game/combat-effects/audio/smite.wav"), "core:combat-media"); assert.equal(contentPackForPath("assets/game/combat-effects/audio/smite.wav"), "core:combat-media");
assert.equal( assert.equal(
contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"), contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"),
@@ -116,10 +116,12 @@ function reportMarkdown(report) {
dungeon.proceduralModels, "|", dungeon.status, "|", dungeon.proceduralModels, "|", dungeon.status, "|",
].join(" ")); ].join(" "));
return [ return [
"# WoW dungeon content report", "# WoW dungeon combat and animation report",
"", "",
"Status: **" + report.status + "**", "Status: **" + report.status + "**",
"", "",
"Scope: executable combat populations and creature animation bindings. This report does not certify instance scripts, doors, interactables, escorts, waves, vehicles, or navigation.",
"",
"Coverage: " + report.summary.dungeons + " dungeons, " + report.summary.mobs "Coverage: " + report.summary.dungeons + " dungeons, " + report.summary.mobs
+ " mobs, " + report.summary.bosses + " bosses, " + report.summary.attacks + " mobs, " + report.summary.bosses + " bosses, " + report.summary.attacks
+ " executable attacks, and " + report.summary.specialAttacks + " special attacks/mechanics.", + " executable attacks, and " + report.summary.specialAttacks + " special attacks/mechanics.",
@@ -177,6 +179,10 @@ for (const dungeon of definitions) {
let specialAttacks = 0; let specialAttacks = 0;
let nativeModels = 0; let nativeModels = 0;
let proceduralModels = 0; let proceduralModels = 0;
const optionalBossEntityIds = new Set((dungeon.objectiveOrder ?? [])
.filter((objective) => objective.optional)
.map((objective) => dungeon.staticSpawns?.find((spawn) => spawn.id === objective.id)?.entityId)
.filter(Boolean));
if (!entities.length) dungeonIssues.push("has no combat entities."); if (!entities.length) dungeonIssues.push("has no combat entities.");
if (!bosses.length) dungeonIssues.push("has no bosses."); if (!bosses.length) dungeonIssues.push("has no bosses.");
@@ -203,7 +209,7 @@ for (const dungeon of definitions) {
attacks += entityAttacks.length; attacks += entityAttacks.length;
const specials = entityAttacks.filter((attack) => attack.id !== "basic-attack"); const specials = entityAttacks.filter((attack) => attack.id !== "basic-attack");
specialAttacks += specials.length; specialAttacks += specials.length;
if (entity.kind === "boss" && !specials.length) { if (entity.kind === "boss" && !specials.length && !optionalBossEntityIds.has(entityId)) {
const exceptionKey = dungeon.id + ":" + entityId; const exceptionKey = dungeon.id + ":" + entityId;
if (!basicOnlyBossExceptions.has(exceptionKey)) { if (!basicOnlyBossExceptions.has(exceptionKey)) {
dungeonIssues.push(entityId + " has no boss mechanic beyond its basic attack."); dungeonIssues.push(entityId + " has no boss mechanic beyond its basic attack.");
@@ -297,7 +303,7 @@ const summary = dungeons.reduce((result, dungeon) => ({
const report = { const report = {
schemaVersion: 1, schemaVersion: 1,
status: issues.length ? "blocked" : "green", status: issues.length ? "blocked" : "green",
scope: "HealerMan WoW five-player campaign", scope: "HealerMan WoW five-player combat and animation coverage",
summary, summary,
documentedExceptions, documentedExceptions,
animationFallbacks, animationFallbacks,
@@ -317,4 +323,4 @@ console.log(JSON.stringify({
documentedExceptions: report.documentedExceptions, documentedExceptions: report.documentedExceptions,
issues: report.issues, issues: report.issues,
}, null, 2)); }, null, 2));
if (issues.length) process.exitCode = 1; if (issues.length) process.exitCode = 1;
@@ -707,6 +707,18 @@ function smartAiMechanicsFor(entity, boss, spells, worldAi) {
return mechanics; return mechanics;
} }
function initializationSpawnChance(entry, worldAi) {
const despawnRows = (worldAi?.smartScriptsByEntry?.[String(entry)] ?? []).filter((row) => (
Number(row.event_type) === 37
&& Number(row.action_type) === 41
&& Number(row.target_type) === 1
));
if (!despawnRows.length) return 1;
return rounded(despawnRows.reduce((chance, row) => (
chance * (1 - Math.max(0, Math.min(100, Number(row.event_chance) || 0)) / 100)
), 1));
}
function uniqueMechanics(mechanics) { function uniqueMechanics(mechanics) {
const seenIds = new Set(); const seenIds = new Set();
const seenSpellIds = new Set(); const seenSpellIds = new Set();
@@ -1233,6 +1245,7 @@ async function compile() {
const id = encounter.eventOnly const id = encounter.eventOnly
? "mutanus-spawn" ? "mutanus-spawn"
: candidate?.id ?? `encounter-${encounter.encounterId}`; : candidate?.id ?? `encounter-${encounter.encounterId}`;
const spawnChance = initializationSpawnChance(encounter.creatureId, azerothCoreWorldAi);
usedBossSpawnIds.add(id); usedBossSpawnIds.add(id);
return { return {
id, id,
@@ -1240,6 +1253,7 @@ async function compile() {
name: encounter.name, name: encounter.name,
position, position,
yaw: candidate?.yaw ?? 0, yaw: candidate?.yaw ?? 0,
...(spawnChance < 1 ? { optional: true } : {}),
...(encounter.eventOnly ? { eventOnly: true } : {}), ...(encounter.eventOnly ? { eventOnly: true } : {}),
}; };
}); });
@@ -1248,6 +1262,7 @@ async function compile() {
const roamingPacks = []; const roamingPacks = [];
for (const spawn of sourceSpawns) { for (const spawn of sourceSpawns) {
const entry = Number(spawn.entityId.replace("entry-", "")); const entry = Number(spawn.entityId.replace("entry-", ""));
const spawnChance = initializationSpawnChance(entry, azerothCoreWorldAi);
const isBoss = bossEntries.has(entry); const isBoss = bossEntries.has(entry);
const transformedPosition = mapPosition(spawn.position); const transformedPosition = mapPosition(spawn.position);
if (!isBoss && spawn.waypoints?.length >= 2) { if (!isBoss && spawn.waypoints?.length >= 2) {
@@ -1269,6 +1284,7 @@ async function compile() {
pathId: spawn.pathId, pathId: spawn.pathId,
formationSource: "database-solo", formationSource: "database-solo",
spawnMask: spawn.spawnMask, spawnMask: spawn.spawnMask,
...(spawnChance < 1 ? { spawnChance } : {}),
waypoints, waypoints,
members: [{ members: [{
id: "member", id: "member",
@@ -1286,6 +1302,7 @@ async function compile() {
position: transformedPosition, position: transformedPosition,
yaw: spawn.yaw, yaw: spawn.yaw,
spawnMask: spawn.spawnMask, spawnMask: spawn.spawnMask,
...(spawnChance < 1 ? { spawnChance } : {}),
}); });
} }
for (const boss of bossObjectives) { for (const boss of bossObjectives) {
@@ -1337,10 +1354,11 @@ async function compile() {
...roamingPacks.flatMap((pack) => pack.waypoints), ...roamingPacks.flatMap((pack) => pack.waypoints),
]; ];
const bounds = finiteBounds(allPoints); const bounds = finiteBounds(allPoints);
const bosses = bossObjectives.map(({ id, name, position, eventOnly }) => ({ const bosses = bossObjectives.map(({ id, name, position, optional, eventOnly }) => ({
id, id,
name, name,
position, position,
...(optional ? { optional: true } : {}),
...(eventOnly ? { eventOnly: true } : {}), ...(eventOnly ? { eventOnly: true } : {}),
})); }));
const databaseKind = recipe.clientBuild === "3.3.5a-Epoch" || draft?.spawns?.length const databaseKind = recipe.clientBuild === "3.3.5a-Epoch" || draft?.spawns?.length
+1
View File
@@ -39,6 +39,7 @@ export declare const ARENA_TICK_MS: 50;
export declare class ArenaSimulation { export declare class ArenaSimulation {
constructor(options: ArenaSimulationOptions); constructor(options: ArenaSimulationOptions);
start(): this; start(): this;
resetPreparationClock(startedAt?: number): boolean;
stop(): void; stop(): void;
step(): void; step(): void;
snapshot(): ArenaAuthoritativeSnapshot; snapshot(): ArenaAuthoritativeSnapshot;
+7
View File
@@ -156,6 +156,13 @@ export class ArenaSimulation {
return this; return this;
} }
resetPreparationClock(startedAt = Date.now()) {
if (this.interval || this.tickNumber !== 0 || this.phase !== "preparation" || this.finished) return false;
this.startedAt = Number(startedAt);
this.phaseEndsAt = this.startedAt + 10_000;
return true;
}
stop() { stop() {
if (this.interval) clearInterval(this.interval); if (this.interval) clearInterval(this.interval);
this.interval = null; this.interval = null;
+8
View File
@@ -26,6 +26,14 @@ test("authoritative simulation seals ten seconds, runs 20 Hz, and rejects duplic
assert.ok(simulation.actor("left-healer").position[0] > before); assert.ok(simulation.actor("left-healer").position[0] > before);
}); });
test("a deferred practice start receives a fresh preparation clock", () => {
const stage = ARENA_STAGES.find((entry) => entry.id === "warnorken-arena");
const simulation = new ArenaSimulation({ id: "deferred", stage, bracket: "2v2", mode: "practice", ruleset: "wow", participants: participants(), startedAt: 1_000 });
assert.equal(simulation.resetPreparationClock(20_000), true);
assert.equal(simulation.snapshot().serverTime, 20_000);
assert.equal(simulation.snapshot().phaseEndsAt, 30_000);
});
test("Dalaran and Ring of Valor mechanics synchronize collision state", () => { test("Dalaran and Ring of Valor mechanics synchronize collision state", () => {
const dalaran = ARENA_STAGES.find((entry) => entry.id === "dalaran-sewers"); const dalaran = ARENA_STAGES.find((entry) => entry.id === "dalaran-sewers");
const warning = arenaDynamicObjectState(dalaran, 27_000).find((entry) => entry.id === "waterfall"); const warning = arenaDynamicObjectState(dalaran, 27_000).find((entry) => entry.id === "waterfall");
+138 -40
View File
@@ -1,56 +1,104 @@
import { Clone } from "@react-three/drei"; import { Clone, useProgress } from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber"; import { Canvas, useFrame } from "@react-three/fiber";
import { Suspense, useEffect, useMemo, useRef, type RefObject } from "react"; import { Component, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { FrontSide, Group, Mesh, Vector3 } from "three"; import { Group, Vector3 } from "three";
import { selectedCharacter, useShellStore } from "./app/shellStore"; import { selectedCharacter, useShellStore } from "./app/shellStore";
import { CharacterModel } from "./avatar/CharacterModel"; import { CharacterModel } from "./avatar/CharacterModel";
import { ProceduralAvatar } from "./avatar/ProceduralAvatar"; import { ProceduralAvatar } from "./avatar/ProceduralAvatar";
import { classById } from "./app/characterCatalog"; import { classById } from "./app/characterCatalog";
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./game/equipment"; import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./game/equipment";
import { useArenaStore } from "./game/arenaStore"; import { useArenaStore } from "./game/arenaStore";
import { arenaCameraPose } from "./game/arenaCamera";
import { arenaStageById } from "./game/arenaStages"; import { arenaStageById } from "./game/arenaStages";
import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes"; import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes";
import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF"; import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF";
function StageEnvironment({ stage }: { stage: ArenaStageManifest }) { class ArenaStageErrorBoundary extends Component<{
const visual = stage.assets.find((asset) => asset.id === "visual")?.url; readonly children: ReactNode;
return visual ? <LoadedStage url={visual} /> : <ProceduralStage stage={stage} />; readonly fallback: ReactNode;
readonly onError: (error: unknown) => void;
}, { readonly failed: boolean }> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch(error: unknown) {
this.props.onError(error);
}
render() {
return this.state.failed ? this.props.fallback : this.props.children;
}
} }
function LoadedStage({ url }: { url: string }) { function StageEnvironment({ stage, onReady }: { stage: ArenaStageManifest; onReady: () => void }) {
const visual = stage.assets.find((asset) => asset.id === "visual")?.url;
return (
<>
<ProceduralStage stage={stage} floorOnly />
{visual ? <LoadedStage url={visual} onReady={onReady} /> : <StageReady onReady={onReady} />}
</>
);
}
function StageReady({ onReady }: { readonly onReady: () => void }) {
useEffect(onReady, [onReady]);
return null;
}
function LoadedStage({ url, onReady }: { url: string; onReady: () => void }) {
const gltf = useGameGLTF(`${url}?arena-runtime=1`); const gltf = useGameGLTF(`${url}?arena-runtime=1`);
useEffect(() => { useEffect(() => {
gltf.scene.traverse((object) => { onReady();
if (!(object instanceof Mesh)) return; }, [gltf.scene, onReady]);
const materials = Array.isArray(object.material) ? object.material : [object.material];
for (const material of materials) {
material.side = FrontSide;
material.needsUpdate = true;
}
});
}, [gltf.scene]);
return <Clone object={gltf.scene} />; return <Clone object={gltf.scene} />;
} }
function ProceduralStage({ stage }: { stage: ArenaStageManifest }) { function ProceduralStage({ stage, floorOnly = false }: { stage: ArenaStageManifest; floorOnly?: boolean }) {
const width = stage.bounds.max[0] - stage.bounds.min[0]; const width = stage.bounds.max[0] - stage.bounds.min[0];
const depth = stage.bounds.max[2] - stage.bounds.min[2]; const depth = stage.bounds.max[2] - stage.bounds.min[2];
const centerX = (stage.bounds.max[0] + stage.bounds.min[0]) / 2; const centerX = (stage.bounds.max[0] + stage.bounds.min[0]) / 2;
const centerZ = (stage.bounds.max[2] + stage.bounds.min[2]) / 2; const centerZ = (stage.bounds.max[2] + stage.bounds.min[2]) / 2;
const floorY = Math.min(...stage.teamSpawns.map((spawn) => spawn[1])) - 0.175;
const wallHeight = 2.4;
return ( return (
<group> <group>
<mesh position={[centerX, stage.bounds.min[1], centerZ]} receiveShadow> <mesh position={[centerX, floorY, centerZ]} receiveShadow>
<boxGeometry args={[width, 0.5, depth]} /> <boxGeometry args={[width, 0.25, depth]} />
<meshStandardMaterial color="#252b27" roughness={0.9} /> <meshStandardMaterial color={floorOnly ? "#364038" : "#3d4d43"} roughness={0.92} />
</mesh> </mesh>
{stage.lineOfSight.map((object) => ( {!floorOnly && (
<mesh key={object.id} position={object.position as [number, number, number]} castShadow receiveShadow> <>
{object.shape === "cylinder" <gridHelper args={[Math.max(width, depth), 24, "#c8aa5c", "#596c5f"]} position={[centerX, floorY + 0.14, centerZ]} />
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} /> {[
: <boxGeometry args={object.size as [number, number, number]} />} [centerX, floorY + wallHeight / 2, stage.bounds.min[2], width, wallHeight, 0.6],
<meshStandardMaterial color="#4e5146" roughness={0.78} /> [centerX, floorY + wallHeight / 2, stage.bounds.max[2], width, wallHeight, 0.6],
</mesh> [stage.bounds.min[0], floorY + wallHeight / 2, centerZ, 0.6, wallHeight, depth],
))} [stage.bounds.max[0], floorY + wallHeight / 2, centerZ, 0.6, wallHeight, depth],
].map(([x, y, z, sizeX, sizeY, sizeZ], index) => (
<mesh key={`boundary-${index}`} position={[x, y, z]} receiveShadow>
<boxGeometry args={[sizeX, sizeY, sizeZ]} />
<meshStandardMaterial color="#26342d" roughness={0.88} />
</mesh>
))}
{stage.teamSpawns.map((spawn, index) => (
<mesh key={`spawn-${index}`} position={[spawn[0], floorY + 0.2, spawn[2]]} receiveShadow>
<cylinderGeometry args={[3.4, 3.4, 0.18, 32]} />
<meshStandardMaterial color={index === 0 ? "#338dcc" : "#bd4944"} emissive={index === 0 ? "#123d5b" : "#541c1a"} emissiveIntensity={0.7} />
</mesh>
))}
{stage.lineOfSight.map((object) => (
<mesh key={object.id} position={object.position as [number, number, number]} castShadow receiveShadow>
{object.shape === "cylinder"
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} />
: <boxGeometry args={object.size as [number, number, number]} />}
<meshStandardMaterial color="#6a6f5e" roughness={0.78} />
</mesh>
))}
</>
)}
</group> </group>
); );
} }
@@ -141,31 +189,35 @@ function ArenaActor({ actor, local, character, movement, stage }: { actor: Arena
function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage: ArenaStageManifest }) { function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage: ArenaStageManifest }) {
const target = useMemo(() => new Vector3(), []); const target = useMemo(() => new Vector3(), []);
const desired = useMemo(() => new Vector3(), []); const desired = useMemo(() => new Vector3(), []);
const pose = useMemo(() => arenaCameraPose(stage, actor?.team ?? 0), [actor?.team, stage]);
useFrame(({ camera }, delta) => { useFrame(({ camera }, delta) => {
if (!actor) return; if (!actor) return;
const spawn = stage.teamSpawns[actor.team]; target.set(...pose.target);
const centerX = (stage.bounds.min[0] + stage.bounds.max[0]) / 2; desired.set(...pose.position);
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.position.lerp(desired, Math.min(1, delta * 5));
camera.lookAt(target); camera.lookAt(target);
}); });
return null; return null;
} }
function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]> }) { function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement, detailedAsset, onStageReady, onStageError }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; detailedAsset: boolean; onStageReady: () => void; onStageError: (error: unknown) => void }) {
return ( return (
<Canvas shadows dpr={[1, 1.35]} camera={{ fov: 52, near: 0.1, far: 1200, position: [0, 16, 24] }}> <Canvas
shadows
dpr={[1, 1.35]}
camera={{ fov: 52, near: 0.1, far: 1200, position: [0, 16, 24] }}
fallback={<div className="arena-render-fallback"><strong>3D arena unavailable</strong><small>This device could not start WebGL. Return to Arena Command and try another renderer.</small></div>}
>
<color attach="background" args={["#111815"]} /> <color attach="background" args={["#111815"]} />
<fog attach="fog" args={["#111815", 55, 240]} /> <fog attach="fog" args={["#111815", 55, 240]} />
<hemisphereLight args={["#c9d8cd", "#101713", 1.55]} /> <hemisphereLight args={["#c9d8cd", "#101713", 1.55]} />
<directionalLight castShadow position={[24, 42, 18]} intensity={2.2} color="#f1d6a2" /> <directionalLight castShadow position={[24, 42, 18]} intensity={2.2} color="#f1d6a2" />
<GameGltfLoaderLifecycle> <GameGltfLoaderLifecycle>
<Suspense fallback={null}><StageEnvironment stage={stage} /></Suspense> {detailedAsset ? (
<ArenaStageErrorBoundary key={stage.id} fallback={null} onError={onStageError}>
<Suspense fallback={<ProceduralStage stage={stage} />}><StageEnvironment stage={stage} onReady={onStageReady} /></Suspense>
</ArenaStageErrorBoundary>
) : <ProceduralStage stage={stage} />}
<DynamicStageObjects stage={stage} objects={dynamicObjects} /> <DynamicStageObjects stage={stage} objects={dynamicObjects} />
{actors.map((actor) => <ArenaActor key={actor.id} actor={actor} local={actor.id === localActor?.id} character={character} movement={movement} stage={stage} />)} {actors.map((actor) => <ArenaActor key={actor.id} actor={actor} local={actor.id === localActor?.id} character={character} movement={movement} stage={stage} />)}
<ArenaCamera actor={localActor} stage={stage} /> <ArenaCamera actor={localActor} stage={stage} />
@@ -174,6 +226,25 @@ function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, move
); );
} }
function ArenaAssetStatus({ stage, ready, fallbackReason }: { readonly stage: ArenaStageManifest; readonly ready: boolean; readonly fallbackReason: string | null }) {
const { active, progress } = useProgress();
if (ready && !active && !fallbackReason) return null;
const value = Math.max(3, Math.min(100, Math.round(progress)));
return (
<section className={`arena-asset-status${fallbackReason ? " arena-asset-status--fallback" : ""}`} role="status" aria-live="polite">
<strong>{fallbackReason ? "Arena ready with safe geometry" : `Loading ${stage.name}`}</strong>
{fallbackReason ? (
<small>The detailed arena asset could not be decoded, so a collision-matched fallback is active.</small>
) : (
<>
<span><i style={{ width: `${value}%` }} /></span>
<small>Streaming arena geometry · {value}%</small>
</>
)}
</section>
);
}
function TeamFrames({ actors, team }: { actors: readonly ArenaActorSnapshot[]; team: 0 | 1 }) { function TeamFrames({ actors, team }: { actors: readonly ArenaActorSnapshot[]; team: 0 | 1 }) {
return ( return (
<div className={`arena-team-frames arena-team-frames--${team}`}> <div className={`arena-team-frames arena-team-frames--${team}`}>
@@ -194,11 +265,36 @@ export default function ArenaRuntime() {
const intent = useArenaStore((state) => state.intent); const intent = useArenaStore((state) => state.intent);
const leave = useArenaStore((state) => state.leave); const leave = useArenaStore((state) => state.leave);
const load = useArenaStore((state) => state.load); const load = useArenaStore((state) => state.load);
const startPractice = useArenaStore((state) => state.startPractice);
const session = useShellStore((state) => state.session); const session = useShellStore((state) => state.session);
const character = useShellStore(selectedCharacter); const character = useShellStore(selectedCharacter);
const stage = snapshot ? arenaStageById(snapshot.stageId) : null; const stage = snapshot ? arenaStageById(snapshot.stageId) : null;
const localActor = snapshot?.actors.find((actor) => actor.id === (snapshot.controlledActorId ?? "practice-player")) ?? null; const localActor = snapshot?.actors.find((actor) => actor.id === (snapshot.controlledActorId ?? "practice-player")) ?? null;
const movementRef = useRef<[number, number]>([0, 0]); const movementRef = useRef<[number, number]>([0, 0]);
const [loadedStageId, setLoadedStageId] = useState<string | null>(null);
const [stageFailure, setStageFailure] = useState<{ readonly stageId: string; readonly reason: string } | null>(null);
const handleStageReady = useCallback(() => {
if (!stage) return;
setLoadedStageId(stage.id);
setStageFailure((current) => current?.stageId === stage.id ? null : current);
startPractice();
}, [stage, startPractice]);
const handleStageError = useCallback((error: unknown) => {
if (!stage) return;
setLoadedStageId(stage.id);
setStageFailure({ stageId: stage.id, reason: error instanceof Error ? error.message : "Arena asset failed to load" });
startPractice();
}, [stage, startPractice]);
useEffect(() => {
if (!stage || loadedStageId === stage.id) return;
const timeout = window.setTimeout(() => {
setLoadedStageId(stage.id);
setStageFailure({ stageId: stage.id, reason: "Arena asset loading timed out" });
startPractice();
}, 30_000);
return () => window.clearTimeout(timeout);
}, [loadedStageId, stage, startPractice]);
useEffect(() => { useEffect(() => {
if (snapshot || !character) return; if (snapshot || !character) return;
@@ -252,9 +348,11 @@ export default function ArenaRuntime() {
if (!snapshot || !stage) return <div className="world-transition"><span>HM</span><strong>Entering arena</strong><small>Waiting for the authoritative match snapshot...</small></div>; if (!snapshot || !stage) return <div className="world-transition"><span>HM</span><strong>Entering arena</strong><small>Waiting for the authoritative match snapshot...</small></div>;
const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null; const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null;
const detailedAsset = stageFailure?.stageId !== stage.id;
return ( return (
<main className="arena-runtime"> <main className="arena-runtime">
<div className="arena-world"><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} /></div> <div className="arena-world" key={`${stage.id}:${detailedAsset ? "detailed" : "fallback"}`}><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} detailedAsset={detailedAsset} onStageReady={handleStageReady} onStageError={handleStageError} /></div>
<ArenaAssetStatus stage={stage} ready={loadedStageId === stage.id} fallbackReason={stageFailure?.stageId === stage.id ? stageFailure.reason : null} />
<header className="arena-match-header"> <header className="arena-match-header">
<span>{stage.name}</span><strong>{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}</strong><em>Dampening {Math.round(snapshot.dampening * 100)}%</em> <span>{stage.name}</span><strong>{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}</strong><em>Dampening {Math.round(snapshot.dampening * 100)}%</em>
</header> </header>
+2
View File
@@ -16,6 +16,7 @@ const STATIC_ANDROID_BUNDLED_PACKS = new Set([
"core:equipment", "core:equipment",
"core:character-basic", "core:character-basic",
"core:runtime", "core:runtime",
"core:arenas",
"dungeon:wailing-caverns", "dungeon:wailing-caverns",
]); ]);
const STATIC_BROWSER_BUNDLED_PACKS = new Set([ const STATIC_BROWSER_BUNDLED_PACKS = new Set([
@@ -23,6 +24,7 @@ const STATIC_BROWSER_BUNDLED_PACKS = new Set([
"core:equipment", "core:equipment",
"core:character-basic", "core:character-basic",
"core:runtime", "core:runtime",
"core:arenas",
"dungeon:wailing-caverns", "dungeon:wailing-caverns",
]); ]);
+6
View File
@@ -36,6 +36,8 @@ export function PartyBridge() {
character.categoryId ?? "wow", character.categoryId ?? "wow",
); );
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory); usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
combat.synchronizeActors();
combat.applyDefaultPartyBuffs();
resetPartyRuntimeClock(); resetPartyRuntimeClock();
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]); }, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
@@ -44,6 +46,8 @@ export function PartyBridge() {
const combat = useCombatStore.getState(); const combat = useCombatStore.getState();
party.syncLevel(level); party.syncLevel(level);
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory); usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
combat.synchronizeActors();
combat.applyDefaultPartyBuffs();
}, [level]); }, [level]);
useEffect(() => { useEffect(() => {
@@ -52,6 +56,8 @@ export function PartyBridge() {
const combat = useCombatStore.getState(); const combat = useCombatStore.getState();
usePartyStore.getState().resetCombat(combat.level, combat.health); usePartyStore.getState().resetCombat(combat.level, combat.health);
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory); usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
combat.synchronizeActors();
combat.applyDefaultPartyBuffs();
resetPartyRuntimeClock(); resetPartyRuntimeClock();
}, [resetRevision]); }, [resetRevision]);
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { arenaCameraPose } from "./arenaCamera";
import { ARENA_STAGES } from "./arenaStages";
describe("arena camera placement", () => {
it("uses a finite overview anchor aimed at the playable center of every arena", () => {
for (const stage of ARENA_STAGES) {
for (const team of [0, 1] as const) {
const { position, target } = arenaCameraPose(stage, team);
expect(position).toEqual(stage.spectatorPositions[team]);
expect(position[0], `${stage.id} team ${team} x`).toBeGreaterThan(stage.bounds.min[0]);
expect(position[0], `${stage.id} team ${team} x`).toBeLessThan(stage.bounds.max[0]);
expect(position[2], `${stage.id} team ${team} z`).toBeGreaterThan(stage.bounds.min[2]);
expect(position[2], `${stage.id} team ${team} z`).toBeLessThan(stage.bounds.max[2]);
expect(target.every(Number.isFinite)).toBe(true);
}
}
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { ArenaStageManifest } from "./arenaTypes";
export interface ArenaCameraPose {
readonly position: [number, number, number];
readonly target: [number, number, number];
}
export function arenaCameraPose(stage: ArenaStageManifest, team: 0 | 1): ArenaCameraPose {
const centerX = (stage.bounds.min[0] + stage.bounds.max[0]) / 2;
const centerZ = (stage.bounds.min[2] + stage.bounds.max[2]) / 2;
const floorY = Math.min(...stage.teamSpawns.map((spawn) => spawn[1]));
return {
position: [...stage.spectatorPositions[team]],
target: [centerX, floorY + 1.2, centerZ],
};
}
+7 -1
View File
@@ -55,9 +55,15 @@ export function createArenaPractice(options: PracticeOptions) {
onSnapshot: (snapshot) => options.onSnapshot({ ...snapshot, controlledActorId: "practice-player" }), onSnapshot: (snapshot) => options.onSnapshot({ ...snapshot, controlledActorId: "practice-player" }),
onResult: (result) => options.onResult({ ...result, ratingUpdates: [] }), onResult: (result) => options.onResult({ ...result, ratingUpdates: [] }),
}); });
simulation.start();
options.onSnapshot({ ...simulation.snapshot(), controlledActorId: "practice-player" }); options.onSnapshot({ ...simulation.snapshot(), controlledActorId: "practice-player" });
let started = false;
return { return {
start() {
if (started) return;
started = true;
simulation.resetPreparationClock();
simulation.start();
},
intent(intent: ArenaCombatIntent) { intent(intent: ArenaCombatIntent) {
return simulation.receiveIntent("practice-player", intent); return simulation.receiveIntent("practice-player", intent);
}, },
+3
View File
@@ -41,6 +41,7 @@ interface ArenaStoreState {
deleteLoadout: (session: PlayerSession, characterId: string, slot: number) => Promise<boolean>; deleteLoadout: (session: PlayerSession, characterId: string, slot: number) => Promise<boolean>;
queueMatch: (session: PlayerSession | null, character: CharacterProfile) => Promise<boolean>; queueMatch: (session: PlayerSession | null, character: CharacterProfile) => Promise<boolean>;
ready: (session: PlayerSession) => Promise<void>; ready: (session: PlayerSession) => Promise<void>;
startPractice: () => void;
leave: (session: PlayerSession | null) => Promise<void>; leave: (session: PlayerSession | null) => Promise<void>;
intent: (intent: Omit<ArenaCombatIntent, "sequence">) => void; intent: (intent: Omit<ArenaCombatIntent, "sequence">) => void;
resetResult: () => void; resetResult: () => void;
@@ -221,6 +222,8 @@ export const useArenaStore = create<ArenaStoreState>((set, get) => ({
catch (error) { set({ error: messageFor(error) }); } catch (error) { set({ error: messageFor(error) }); }
}, },
startPractice: () => get().practice?.start(),
leave: async (session) => { leave: async (session) => {
get().practice?.stop(); get().practice?.stop();
get().socket?.close(); get().socket?.close();
+42
View File
@@ -24,6 +24,8 @@ describe("named aura combat runtime", () => {
it("applies and expires an imported named player buff", () => { it("applies and expires an imported named player buff", () => {
const now = 10_000; const now = 10_000;
const baseHealth = useCombatStore.getState().health;
const baseMaxHealth = useCombatStore.getState().maxHealth;
const result = useCombatStore.getState().castAbility( const result = useCombatStore.getState().castAbility(
"wow335-priest-power-word-fortitude", "wow335-priest-power-word-fortitude",
undefined, undefined,
@@ -46,9 +48,49 @@ describe("named aura combat runtime", () => {
))?.icon, ))?.icon,
}, },
}); });
expect(useCombatStore.getState().maxHealth).toBeGreaterThan(baseMaxHealth);
expect(useCombatStore.getState().health - baseHealth).toBe(
useCombatStore.getState().maxHealth - baseMaxHealth,
);
useCombatStore.getState().tick(0, now + 1_800_000); useCombatStore.getState().tick(0, now + 1_800_000);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === fortitude?.definition.id)).toBe(false); expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === fortitude?.definition.id)).toBe(false);
expect(useCombatStore.getState()).toMatchObject({ health: baseHealth, maxHealth: baseMaxHealth });
});
it("grants learned long-term stat buffs to the full party on load", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 8 });
usePartyStore.getState().initializeParty("default-party-buffs", 8, useCombatStore.getState().health);
useCombatStore.getState().synchronizeActors();
const playerMaxHealth = useCombatStore.getState().maxHealth;
const memberMaxHealth = new Map(usePartyStore.getState().members.map((member) => [member.id, member.maxHealth]));
useCombatStore.getState().applyDefaultPartyBuffs(20_000);
const state = useCombatStore.getState();
const targetIds = [PLAYER_AGGRO_ID, ...usePartyStore.getState().members.map((member) => member.id)];
for (const targetId of targetIds) {
expect(state.auras).toContainEqual(expect.objectContaining({
targetId,
definition: expect.objectContaining({ exclusiveGroup: "default-party-buff:fortitude" }),
}));
}
expect(state.maxHealth).toBeGreaterThan(playerMaxHealth);
for (const member of usePartyStore.getState().members) {
expect(member.maxHealth).toBeGreaterThan(memberMaxHealth.get(member.id)!);
}
const buffedPlayerMaxHealth = state.maxHealth;
expect(state.auras.filter((aura) => (
aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.exclusiveGroup === "default-party-buff:fortitude"
))).toHaveLength(1);
expect(state.castAbility("wow335-priest-power-word-fortitude", undefined, 21_000).ok).toBe(true);
expect(useCombatStore.getState().maxHealth).toBe(buffedPlayerMaxHealth);
expect(useCombatStore.getState().auras.filter((aura) => (
aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.exclusiveGroup === "default-party-buff:fortitude"
))).toHaveLength(1);
}); });
it("consumes a named absorb before player health", () => { it("consumes a named absorb before player health", () => {
+12
View File
@@ -27,6 +27,18 @@ describe("authentic combat resources", () => {
expect(outsideRule.current).toBe(62); expect(outsideRule.current).toBe(62);
}); });
it("regenerates at least fifteen percent mana per five seconds out of combat", () => {
const mana = normalizeActorResourcePool("mana", 1_000, 3_000, 4, 9_000);
const regenerated = advanceCombatResourcePool(mana, {
inCombat: false,
actorLevel: 8,
now: 10_000,
deltaSeconds: 1,
manaSpiritRegenerationPerSecond: 3,
});
expect(regenerated.current).toBe(1_090);
});
it("decays rage and runic power only out of combat", () => { it("decays rage and runic power only out of combat", () => {
const rage = normalizeActorResourcePool("rage", 20, 100); const rage = normalizeActorResourcePool("rage", 20, 100);
const runic = normalizeActorResourcePool("runic-power", 20, 100); const runic = normalizeActorResourcePool("runic-power", 20, 100);
+4
View File
@@ -6,6 +6,7 @@ import {
} from "./combatActors"; } from "./combatActors";
export const MANA_FIVE_SECOND_RULE_MS = 5_000; export const MANA_FIVE_SECOND_RULE_MS = 5_000;
export const OUT_OF_COMBAT_MANA_REGEN_PER_FIVE_SECONDS = 0.15;
export const DEATH_KNIGHT_RUNE_RECHARGE_MS = 10_000; export const DEATH_KNIGHT_RUNE_RECHARGE_MS = 10_000;
export interface CombatResourceAdvanceRules { export interface CombatResourceAdvanceRules {
@@ -24,6 +25,9 @@ export function advanceCombatResourcePool(
if (pool.id === "mana" && rules.now - pool.lastSpentAt < MANA_FIVE_SECOND_RULE_MS) { if (pool.id === "mana" && rules.now - pool.lastSpentAt < MANA_FIVE_SECOND_RULE_MS) {
rate = Math.max(0, rate - Math.max(0, rules.manaSpiritRegenerationPerSecond ?? 0)); rate = Math.max(0, rate - Math.max(0, rules.manaSpiritRegenerationPerSecond ?? 0));
} }
if (pool.id === "mana" && !rules.inCombat) {
rate = Math.max(rate, pool.maximum * OUT_OF_COMBAT_MANA_REGEN_PER_FIVE_SECONDS / 5);
}
if ((pool.id === "rage" || pool.id === "runic-power") && !rules.inCombat) { if ((pool.id === "rage" || pool.id === "runic-power") && !rules.inCombat) {
rate = pool.id === "rage" ? -3 : -2.5; rate = pool.id === "rage" ? -3 : -2.5;
} }
+40 -6
View File
@@ -13,6 +13,7 @@ import {
MINIMUM_CORPSE_VISIBLE_MS, MINIMUM_CORPSE_VISIBLE_MS,
MOB_WOUND_ANIMATION_COOLDOWN_MS, MOB_WOUND_ANIMATION_COOLDOWN_MS,
PLAYER_AURA_ENTITY_ID, PLAYER_AURA_ENTITY_ID,
resolvedAbilityCosts,
selectedMob, selectedMob,
useCombatStore, useCombatStore,
} from "./combatStore"; } from "./combatStore";
@@ -20,7 +21,7 @@ import { TALENT_NODES, talentNodesForTree } from "./talentCatalog";
import { EMPTY_ITEM_STATS } from "./itemStats"; import { EMPTY_ITEM_STATS } from "./itemStats";
import { abilitiesForClass, defaultActionBarForClass } from "./abilityCatalog"; import { abilitiesForClass, defaultActionBarForClass } from "./abilityCatalog";
import { defaultActionBindings } from "./actionBindings"; import { defaultActionBindings } from "./actionBindings";
import { deriveCharacterStats } from "./wotlkStats"; import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation"; import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds"; import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
@@ -82,7 +83,7 @@ describe("combat store", () => {
const first = useCombatStore.getState().castSlot(0, undefined, 1000); const first = useCombatStore.getState().castSlot(0, undefined, 1000);
expect(first).toMatchObject({ ok: true, abilityId: "mage-frostbolt", damage: 0 }); expect(first).toMatchObject({ ok: true, abilityId: "mage-frostbolt", damage: 0 });
expect(useCombatStore.getState().mobs.ravager.health).toBe(100); expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
expect(startingResource).toBe(165); expect(startingResource).toBe(165 * MANA_POOL_MULTIPLIER);
expect(useCombatStore.getState().resource).toBe(startingResource); expect(useCombatStore.getState().resource).toBe(startingResource);
expect(useCombatStore.getState().activeCast).toMatchObject({ completesAt: 2500, mode: "cast" }); expect(useCombatStore.getState().activeCast).toMatchObject({ completesAt: 2500, mode: "cast" });
@@ -92,11 +93,42 @@ describe("combat store", () => {
expect(useCombatStore.getState().mobs.ravager.health).toBe(100); expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
useCombatStore.getState().tick(0.1, 2500); useCombatStore.getState().tick(0.1, 2500);
expect(useCombatStore.getState().mobs.ravager.health).toBeLessThan(100); expect(useCombatStore.getState().mobs.ravager.health).toBeLessThan(100);
expect(useCombatStore.getState().resource).toBe(startingResource - Math.round(startingResource * 0.14)); expect(useCombatStore.getState().resource).toBe(
startingResource - Math.round(startingResource / MANA_POOL_MULTIPLIER * 0.14),
);
expect(useCombatStore.getState().activeCast).toBeNull(); expect(useCombatStore.getState().activeCast).toBeNull();
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true); expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
}); });
it("uses the displayed primary mana and resolved percentage cost for affordability", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
const initial = useCombatStore.getState();
const frostbolt = initial.abilities.find((ability) => ability.id === "mage-frostbolt");
expect(frostbolt).toBeDefined();
const [cost] = resolvedAbilityCosts(frostbolt!, initial.talentModifiers, initial);
expect(cost).toEqual({
resource: "mana",
amount: Math.round(initial.maxResource / MANA_POOL_MULTIPLIER * 0.14),
});
registerTarget("mana-target", [10, 0, 0]);
useCombatStore.setState({
resource: cost.amount,
resourcePools: { ...initial.resourcePools, mana: 0 },
});
expect(useCombatStore.getState().castAbility(frostbolt!.id, [0, 0, 0], 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().cancelCast();
useCombatStore.setState({
resource: cost.amount - 1,
resourcePools: { ...initial.resourcePools, mana: initial.maxResource },
});
expect(useCombatStore.getState().castAbility(frostbolt!.id, [0, 0, 0], 3_000)).toMatchObject({
ok: false,
reason: "not-enough-resource",
});
});
it("emits exactly one cast and release event and cancels presentation with the cast", () => { it("emits exactly one cast and release event and cancels presentation with the cast", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 }); useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
registerTarget("presentation-target", [10, 0, 0]); registerTarget("presentation-target", [10, 0, 0]);
@@ -181,8 +213,8 @@ describe("combat store", () => {
const chronomancerBase = deriveCharacterStats("chronomancer", 1, EMPTY_ITEM_STATS, "human"); const chronomancerBase = deriveCharacterStats("chronomancer", 1, EMPTY_ITEM_STATS, "human");
useCombatStore.getState().initializeCharacter({ classId: "chronomancer", level: 1 }); useCombatStore.getState().initializeCharacter({ classId: "chronomancer", level: 1 });
expect(useCombatStore.getState()).toMatchObject({ expect(useCombatStore.getState()).toMatchObject({
resource: Math.round(chronomancerBase.mana * 1.12), resource: Math.round(chronomancerBase.mana / MANA_POOL_MULTIPLIER * 1.12) * MANA_POOL_MULTIPLIER,
maxResource: Math.round(chronomancerBase.mana * 1.12), maxResource: Math.round(chronomancerBase.mana / MANA_POOL_MULTIPLIER * 1.12) * MANA_POOL_MULTIPLIER,
}); });
useCombatStore.getState().initializeCharacter({ classId: "barbarian", level: 10 }); useCombatStore.getState().initializeCharacter({ classId: "barbarian", level: 10 });
@@ -1038,7 +1070,9 @@ describe("combat store", () => {
expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true); expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true);
const boltCompletion = useCombatStore.getState().activeCast!.completesAt; const boltCompletion = useCombatStore.getState().activeCast!.completesAt;
useCombatStore.getState().tick(0.1, boltCompletion); useCombatStore.getState().tick(0.1, boltCompletion);
expect(useCombatStore.getState().resource).toBe(startingMana - Math.round(startingMana * 0.1 * 0.85)); expect(useCombatStore.getState().resource).toBe(
startingMana - Math.round(startingMana / MANA_POOL_MULTIPLIER * 0.1 * 0.85),
);
}); });
it("routes a cast-time direct heal to the selected living party member", () => { it("routes a cast-time direct heal to the selected living party member", () => {
+259 -26
View File
@@ -155,6 +155,8 @@ import {
deriveCharacterStats, deriveCharacterStats,
equipmentAttackPower, equipmentAttackPower,
hastedDuration, hastedDuration,
healthFromTotalStamina,
MANA_POOL_MULTIPLIER,
ratingPercent, ratingPercent,
type CharacterStats, type CharacterStats,
type WotlkAttackKind, type WotlkAttackKind,
@@ -673,6 +675,8 @@ export interface CombatState {
confirmGroundTarget: (position?: CombatPosition, now?: number) => CastResult; confirmGroundTarget: (position?: CombatPosition, now?: number) => CastResult;
cancelGroundTarget: () => void; cancelGroundTarget: () => void;
synchronizeActors: () => void; synchronizeActors: () => void;
/** Grants learned long-term class stat buffs to the full party without setup casts. */
applyDefaultPartyBuffs: (now?: number) => void;
/** Commits companion simulation results to authoritative actors and mirrors party frames. */ /** Commits companion simulation results to authoritative actors and mirrors party frames. */
commitCompanionActors: (members: readonly import("./partyStore").PartyMember[]) => void; commitCompanionActors: (members: readonly import("./partyStore").PartyMember[]) => void;
castSlot: (index: number, origin?: CombatPosition, now?: number) => CastResult; castSlot: (index: number, origin?: CombatPosition, now?: number) => CastResult;
@@ -1504,17 +1508,23 @@ function initialResourcePools(
return { pools, maximums }; return { pools, maximums };
} }
function abilityCosts( export function resolvedAbilityCosts(
ability: AbilityDefinition, ability: AbilityDefinition,
modifiers: readonly TalentModifier[], modifiers: readonly TalentModifier[],
state: Pick<CombatState, "maxHealth" | "maxResourcePools"> & Partial<Pick<CombatState, "auras">>, state: Pick<CombatState, "classId" | "maxHealth" | "maxResource" | "maxResourcePools"> & Partial<Pick<CombatState, "auras">>,
): readonly { resource: ResourceType; amount: number }[] { ): readonly { resource: ResourceType; amount: number }[] {
const aggregated = new Map<ResourceType, number>(); const aggregated = new Map<ResourceType, number>();
const activeResourceType = resourceProfileForClass(state.classId).type;
for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) { for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) {
const maximum = cost.resource === "health" const maximum = cost.resource === "health"
? state.maxHealth ? state.maxHealth
: state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource); : cost.resource === activeResourceType
const baseAmount = cost.percentage ? maximum * cost.amount / 100 : cost.amount; ? state.maxResource
: state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource);
const percentageMaximum = cost.resource === "mana" && ability.source === "wow335-progression"
? maximum / MANA_POOL_MULTIPLIER
: maximum;
const baseAmount = cost.percentage ? percentageMaximum * cost.amount / 100 : cost.amount;
const rawAmount = Math.max(0, Math.round(applyTalentModifierValue( const rawAmount = Math.max(0, Math.round(applyTalentModifierValue(
baseAmount, baseAmount,
modifiers, modifiers,
@@ -1886,11 +1896,16 @@ function auraDefinitionForAbility(
ability: AbilityDefinition, ability: AbilityDefinition,
durationMs = definition.durationMs, durationMs = definition.durationMs,
): AuraDefinition { ): AuraDefinition {
const defaultPartyBuffFamily = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase());
return { return {
...definition, ...definition,
description: definition.description ?? ability.description, description: definition.description ?? ability.description,
icon: definition.icon ?? ability.icon, icon: definition.icon ?? ability.icon,
durationMs, durationMs,
...(defaultPartyBuffFamily ? {
uniqueBySource: false,
exclusiveGroup: `default-party-buff:${defaultPartyBuffFamily}`,
} : {}),
}; };
} }
@@ -1913,14 +1928,16 @@ function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): Cha
"all-resistance", "all-resistance",
"magical-defense", "magical-defense",
); );
const stamina = primary(base.stamina, "stamina");
const staminaHealth = healthFromTotalStamina(stamina) - healthFromTotalStamina(base.stamina);
return { return {
...base, ...base,
strength: primary(base.strength, "strength"), strength: primary(base.strength, "strength"),
agility: primary(base.agility, "agility", "dexterity"), agility: primary(base.agility, "agility", "dexterity"),
stamina: primary(base.stamina, "stamina"), stamina,
intellect: primary(base.intellect, "intellect"), intellect: primary(base.intellect, "intellect"),
spirit: primary(base.spirit, "spirit", "wisdom"), spirit: primary(base.spirit, "spirit", "wisdom"),
health: stat(base.health, "maximum-health"), health: stat(base.health + staminaHealth, "maximum-health"),
mana: stat(base.mana, "maximum-mana"), mana: stat(base.mana, "maximum-mana"),
attackPower: stat(base.attackPower, "attack-power", "physical-attack"), attackPower: stat(base.attackPower, "attack-power", "physical-attack"),
rangedAttackPower: stat(base.rangedAttackPower, "ranged-attack-power", "physical-attack"), rangedAttackPower: stat(base.rangedAttackPower, "ranged-attack-power", "physical-attack"),
@@ -1948,6 +1965,53 @@ function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): Cha
}; };
} }
function auraMaximumHealthBonus(
base: CharacterStats,
auras: readonly ActiveAura[],
targetId: string,
): number {
return Math.round(applyAuraStats(base, aurasForEntity(auras, targetId)).health - base.health);
}
function auraAdjustedPlayerMaxHealth(
classId: ClassId,
level: number,
modifiers: readonly TalentModifier[],
gearStats: ItemStats,
raceId: RaceId,
secondaryClassId: ClassId | null | undefined,
auras: readonly ActiveAura[],
): number {
const baseStats = playerCharacterStats(classId, level, gearStats, raceId, secondaryClassId ?? null, modifiers);
return Math.max(
1,
talentAdjustedMaxHealth(classId, level, modifiers, gearStats, raceId, secondaryClassId)
+ auraMaximumHealthBonus(baseStats, auras, PLAYER_AURA_ENTITY_ID),
);
}
const DEFAULT_PARTY_BUFF_FAMILY = Object.freeze(new Map<string, string>([
["power word: fortitude", "fortitude"],
["prayer of fortitude", "fortitude"],
["divine spirit", "spirit"],
["prayer of spirit", "spirit"],
["shadow protection", "shadow-protection"],
["prayer of shadow protection", "shadow-protection"],
["arcane intellect", "arcane-intellect"],
["arcane brilliance", "arcane-intellect"],
["mark of the wild", "mark-of-the-wild"],
["gift of the wild", "mark-of-the-wild"],
]));
function defaultPartyBuffPotency(
effects: readonly Extract<AbilityEffect, { kind: "apply-aura" }>[],
): number {
return effects.reduce((total, effect) => total + (effect.aura.modifiers ?? []).reduce(
(subtotal, modifier) => subtotal + Math.abs(modifier.value) * (modifier.operation === "percent" ? 1_000 : 1),
0,
), 0);
}
function syncPassiveAuras( function syncPassiveAuras(
current: readonly ActiveAura[], current: readonly ActiveAura[],
abilities: readonly AbilityDefinition[], abilities: readonly AbilityDefinition[],
@@ -2196,13 +2260,15 @@ function grantMobExperience(
progression.levelsGained += result.levelsGained; progression.levelsGained += result.levelsGained;
if (result.levelsGained > 0) { if (result.levelsGained > 0) {
work.talentModifiers = characterStatModifiers(classId, result.level, work.talentRanks); work.talentModifiers = characterStatModifiers(classId, result.level, work.talentRanks);
progression.maxHealth = talentAdjustedMaxHealth( work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow());
progression.maxHealth = auraAdjustedPlayerMaxHealth(
classId, classId,
result.level, result.level,
work.talentModifiers, work.talentModifiers,
work.gearStats, work.gearStats,
work.raceId, work.raceId,
work.secondaryClassId, work.secondaryClassId,
work.auras,
); );
progression.health = progression.maxHealth; progression.health = progression.maxHealth;
work.health = progression.health; work.health = progression.health;
@@ -2215,7 +2281,6 @@ function grantMobExperience(
work.raceId, work.raceId,
work.secondaryClassId, work.secondaryClassId,
); );
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow());
} }
return scaledAward; return scaledAward;
} }
@@ -2719,12 +2784,14 @@ function executeTriggeredSpellInWork(
const recipient = effect.recipient === "caster" const recipient = effect.recipient === "caster"
? sourceId ? sourceId
: targetId ?? sourceId; : targetId ?? sourceId;
work.auras = [...applyCombatAura(work.auras, { const previousAuras = work.auras;
work.auras = [...applyCombatAura(previousAuras, {
definition: auraDefinitionForAbility(effect.aura, ability), definition: auraDefinitionForAbility(effect.aura, ability),
sourceId, sourceId,
targetId: recipient, targetId: recipient,
now, now,
}).auras]; }).auras];
applyAuraHealthTransitionInWork(work, recipient, previousAuras, work.auras);
if (effect.control && work.mobs[recipient]) { if (effect.control && work.mobs[recipient]) {
work.mobs[recipient] = applyStatus(work.mobs[recipient], { work.mobs[recipient] = applyStatus(work.mobs[recipient], {
kind: "status", kind: "status",
@@ -2963,6 +3030,47 @@ function mirrorCompanionActor(actor: CombatActorState): void {
}); });
} }
function applyAuraHealthTransitionInWork(
work: MutableCastWork,
targetId: string,
previousAuras: readonly ActiveAura[],
nextAuras: readonly ActiveAura[],
): void {
if (targetId === PLAYER_AURA_ENTITY_ID) {
const maxHealth = auraAdjustedPlayerMaxHealth(
work.classId,
work.progression.level,
work.talentModifiers,
work.gearStats,
work.raceId,
work.secondaryClassId,
nextAuras,
);
const delta = maxHealth - work.maxHealth;
if (!delta) return;
work.maxHealth = maxHealth;
work.health = work.health <= 0 ? 0 : clamp(work.health + delta, 1, work.maxHealth);
work.progression.maxHealth = work.maxHealth;
work.progression.health = work.health;
const actor = work.actors[targetId];
if (actor) work.actors[targetId] = { ...actor, health: work.health, maxHealth: work.maxHealth };
return;
}
const member = usePartyStore.getState().members.find((candidate) => candidate.id === targetId);
const actor = ensureCompanionActorInWork(work, targetId);
if (!member || !actor) return;
const base = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
const delta = auraMaximumHealthBonus(base, nextAuras, targetId)
- auraMaximumHealthBonus(base, previousAuras, targetId);
if (!delta) return;
const maxHealth = Math.max(1, actor.maxHealth + delta);
const health = actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, maxHealth);
const nextActor = Object.freeze({ ...actor, health, maxHealth });
work.actors[targetId] = nextActor;
mirrorCompanionActor(nextActor);
}
function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number { function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number {
const actor = ensureCompanionActorInWork(work, id); const actor = ensureCompanionActorInWork(work, id);
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0)); const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
@@ -3265,13 +3373,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") const gearStats = state.specializationId !== null || state.classId.startsWith("rom-")
? resolvedEquipmentStats(state.equipment, state.inventory, context) ? resolvedEquipmentStats(state.equipment, state.inventory, context)
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId); : activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = talentAdjustedMaxHealth( const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId, state.classId,
state.level, state.level,
state.talentModifiers, state.talentModifiers,
gearStats, gearStats,
state.raceId, state.raceId,
state.secondaryClassId, state.secondaryClassId,
state.auras,
); );
const maxResource = talentAdjustedMaxResource( const maxResource = talentAdjustedMaxResource(
state.classId, state.classId,
@@ -3539,13 +3648,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
role: state.gearRole, role: state.gearRole,
level: state.level, level: state.level,
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId); }) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = talentAdjustedMaxHealth( const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId, state.classId,
state.level, state.level,
state.talentModifiers, state.talentModifiers,
gearStats, gearStats,
state.raceId, state.raceId,
state.secondaryClassId, state.secondaryClassId,
state.auras,
); );
const maxResource = talentAdjustedMaxResource( const maxResource = talentAdjustedMaxResource(
state.classId, state.classId,
@@ -3588,13 +3698,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
role: state.gearRole, role: state.gearRole,
level: state.level, level: state.level,
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId); }) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = talentAdjustedMaxHealth( const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId, state.classId,
state.level, state.level,
state.talentModifiers, state.talentModifiers,
gearStats, gearStats,
state.raceId, state.raceId,
state.secondaryClassId, state.secondaryClassId,
state.auras,
); );
const maxResource = talentAdjustedMaxResource( const maxResource = talentAdjustedMaxResource(
state.classId, state.classId,
@@ -3811,6 +3922,59 @@ export const useCombatStore = create<CombatState>((set, get) => ({
members: party.members.map((member) => memberFromCompanionActor(member, actors[member.id])), members: party.members.map((member) => memberFromCompanionActor(member, actors[member.id])),
})); }));
}, },
applyDefaultPartyBuffs: (now = combatNow()) => {
get().synchronizeActors();
const state = get();
const partyMembers = usePartyStore.getState().members;
const sources = [
{
id: PLAYER_AURA_ENTITY_ID,
level: state.level,
abilities: state.abilities,
},
...partyMembers.filter((member) => member.health > 0).map((member) => ({
id: member.id,
level: member.level,
abilities: abilitiesForClass(member.classId),
})),
];
const selected = new Map<string, {
sourceId: string;
ability: AbilityDefinition;
effects: readonly Extract<AbilityEffect, { kind: "apply-aura" }>[];
potency: number;
}>();
for (const source of sources) {
for (const catalogAbility of source.abilities) {
const family = DEFAULT_PARTY_BUFF_FAMILY.get(catalogAbility.name.toLowerCase());
if (!family || catalogAbility.passive || !isAbilityUnlocked(catalogAbility, source.level)) continue;
const ability = abilityAtLevel(catalogAbility, source.level);
const effects = ability.effects.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => (
effect.kind === "apply-aura" && effect.aura.disposition === "buff"
));
if (!effects.length) continue;
const potency = defaultPartyBuffPotency(effects);
if ((selected.get(family)?.potency ?? Number.NEGATIVE_INFINITY) >= potency) continue;
selected.set(family, { sourceId: source.id, ability, effects, potency });
}
}
const recipients = [PLAYER_AURA_ENTITY_ID, ...partyMembers.filter((member) => member.health > 0).map((member) => member.id)];
for (const [family, buff] of selected) {
for (const recipientId of recipients) {
for (const effect of buff.effects) {
get().applyActorAura(buff.sourceId, recipientId, buff.ability, {
...effect,
aura: {
...effect.aura,
uniqueBySource: false,
exclusiveGroup: `default-party-buff:${family}`,
},
}, now);
}
}
}
get().synchronizeActors();
},
commitCompanionActors: (members) => { commitCompanionActors: (members) => {
let actors: Readonly<Record<string, CombatActorState>> = {}; let actors: Readonly<Record<string, CombatActorState>> = {};
set((state) => { set((state) => {
@@ -3866,7 +4030,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: ability?.target; : ability?.target;
const modifiers = state.talentModifiers; const modifiers = state.talentModifiers;
const resourceCost = ability ? talentAdjustedResourceCost(ability, modifiers) : 0; const resourceCost = ability ? talentAdjustedResourceCost(ability, modifiers) : 0;
const costs = ability ? abilityCosts(ability, modifiers, state) : []; const costs = ability ? resolvedAbilityCosts(ability, modifiers, state) : [];
const costsToSpend = completingCast?.mode === "channel" ? [] : costs; const costsToSpend = completingCast?.mode === "channel" ? [] : costs;
const targeting = ability ? targetingRulesForAbility(ability) : null; const targeting = ability ? targetingRulesForAbility(ability) : null;
const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical"; const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical";
@@ -3895,7 +4059,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
else if (ability.id === "rom-mage-primary-56" && !hasRuneWakerState("rom-static-field-charge")) failure = "condition-not-met"; else if (ability.id === "rom-mage-primary-56" && !hasRuneWakerState("rom-static-field-charge")) failure = "condition-not-met";
else if (costs.some((cost) => cost.resource === "health" else if (costs.some((cost) => cost.resource === "health"
? state.health <= cost.amount ? state.health <= cost.amount
: (state.resourcePools[cost.resource] ?? (cost.resource === resourceProfileForClass(state.classId).type ? state.resource : 0)) < cost.amount)) failure = "not-enough-resource"; : (cost.resource === resourceProfileForClass(state.classId).type
? state.resource
: state.resourcePools[cost.resource] ?? 0) < cost.amount)) failure = "not-enough-resource";
else if (runeCost && !spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)) failure = "not-enough-runes"; else if (runeCost && !spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)) failure = "not-enough-runes";
const playerOrigin = currentOrigin(state, origin); const playerOrigin = currentOrigin(state, origin);
@@ -4060,7 +4226,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
state.classId, state.classId,
); );
const cooldownMs = talentAdjustedCooldown(ability, modifiers); const cooldownMs = talentAdjustedCooldown(ability, modifiers);
const spentPools = { ...state.resourcePools }; const activeResourceType = resourceProfileForClass(state.classId).type;
const spentPools: Partial<Record<ResourceType, number>> = {
...state.resourcePools,
[activeResourceType]: state.resource,
};
const lastResourceSpentAt = { ...state.lastResourceSpentAt }; const lastResourceSpentAt = { ...state.lastResourceSpentAt };
let healthAfterCosts = state.health; let healthAfterCosts = state.health;
for (const cost of costsToSpend) { for (const cost of costsToSpend) {
@@ -4073,7 +4243,6 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const spentRunes = runeCost && completingCast?.mode !== "channel" const spentRunes = runeCost && completingCast?.mode !== "channel"
? spendDeathKnightRunes(state.deathKnightRunes, runeCost, now) ? spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)
: state.deathKnightRunes; : state.deathKnightRunes;
const activeResourceType = resourceProfileForClass(state.classId).type;
const activeResourceAfterCosts = spentPools[activeResourceType] ?? state.resource - resourceCost; const activeResourceAfterCosts = spentPools[activeResourceType] ?? state.resource - resourceCost;
if (!completingCast && castTimeMs > 0) { if (!completingCast && castTimeMs > 0) {
castSequence += 1; castSequence += 1;
@@ -4752,12 +4921,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
? affectedMobIds ? affectedMobIds
: [targetId ?? PLAYER_AURA_ENTITY_ID]; : [targetId ?? PLAYER_AURA_ENTITY_ID];
for (const recipientId of recipients) { for (const recipientId of recipients) {
work.auras = [...applyCombatAura(work.auras, { const previousAuras = work.auras;
work.auras = [...applyCombatAura(previousAuras, {
definition: auraDefinition, definition: auraDefinition,
sourceId: PLAYER_AURA_ENTITY_ID, sourceId: PLAYER_AURA_ENTITY_ID,
targetId: recipientId, targetId: recipientId,
now, now,
}).auras]; }).auras];
applyAuraHealthTransitionInWork(work, recipientId, previousAuras, work.auras);
if (auraDefinition.controlImmunities?.length) { if (auraDefinition.controlImmunities?.length) {
if ( if (
recipientId === PLAYER_AURA_ENTITY_ID recipientId === PLAYER_AURA_ENTITY_ID
@@ -5725,8 +5896,49 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetId: targetActorId, targetId: targetActorId,
now, now,
}); });
let health = state.health;
let maxHealth = state.maxHealth;
const actors = { ...state.actors };
let companionActor: CombatActorState | null = null;
if (targetActorId === PLAYER_AURA_ENTITY_ID) {
const nextMaxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
state.talentModifiers,
state.gearStats,
state.raceId,
state.secondaryClassId,
result.auras,
);
const delta = nextMaxHealth - maxHealth;
maxHealth = nextMaxHealth;
health = health <= 0 ? 0 : clamp(health + delta, 1, maxHealth);
const actor = actors[targetActorId];
if (actor) actors[targetActorId] = { ...actor, health, maxHealth };
} else {
const member = usePartyStore.getState().members.find((candidate) => candidate.id === targetActorId);
const actor = member ? companionActorFromMember(
member,
entityAbsorbAmount(state.auras, targetActorId),
state.actors[targetActorId],
) : null;
if (member && actor) {
const base = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
const delta = auraMaximumHealthBonus(base, result.auras, targetActorId)
- auraMaximumHealthBonus(base, state.auras, targetActorId);
if (delta) {
const actorMaxHealth = Math.max(1, actor.maxHealth + delta);
const actorHealth = actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, actorMaxHealth);
companionActor = Object.freeze({ ...actor, health: actorHealth, maxHealth: actorMaxHealth });
actors[targetActorId] = companionActor;
}
}
}
set({ set({
auras: result.auras, auras: result.auras,
health,
maxHealth,
actors,
combatEvents: recordCombatEvent(state.combatEvents, { combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: now, occurredAt: now,
kind: "aura", kind: "aura",
@@ -5741,6 +5953,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
critical: false, critical: false,
}), }),
}); });
if (companionActor) mirrorCompanionActor(companionActor);
return true; return true;
}, },
@@ -7020,6 +7233,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const delta = clamp(Number.isFinite(deltaSeconds) ? deltaSeconds : 0, 0, 1); const delta = clamp(Number.isFinite(deltaSeconds) ? deltaSeconds : 0, 0, 1);
const resourceProfile = resourceProfileForClass(state.classId); const resourceProfile = resourceProfileForClass(state.classId);
const inCombat = Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead); const inCombat = Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead);
const auraExpiration = expireAuras(state.auras, now);
const activeAuras = [...auraExpiration.active];
const baseRegeneration = applyTalentModifierValue( const baseRegeneration = applyTalentModifierValue(
(resourceProfile.type === "rage" || resourceProfile.type === "runic-power") (resourceProfile.type === "rage" || resourceProfile.type === "runic-power")
? 0 ? 0
@@ -7029,7 +7244,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
state.talentModifiers, state.talentModifiers,
"resource-regeneration-percent", "resource-regeneration-percent",
); );
const playerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID); const playerAuras = aurasForEntity(activeAuras, PLAYER_AURA_ENTITY_ID);
const regeneration = resolveAuraResourceValue( const regeneration = resolveAuraResourceValue(
resolveAuraResourceValue(baseRegeneration, playerAuras, resourceProfile.type, "regeneration"), resolveAuraResourceValue(baseRegeneration, playerAuras, resourceProfile.type, "regeneration"),
playerAuras, playerAuras,
@@ -7089,7 +7304,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
abilities: state.abilities, abilities: state.abilities,
mobs: { ...state.mobs }, mobs: { ...state.mobs },
effects: [], effects: [],
auras: [...expireAuras(state.auras, now).active], auras: activeAuras,
summons: state.summons.filter((summon) => summon.expiresAt > now), summons: state.summons.filter((summon) => summon.expiresAt > now),
areaEffects: state.areaEffects.filter((effect) => effect.expiresAt > now), areaEffects: state.areaEffects.filter((effect) => effect.expiresAt > now),
combatEvents: [...state.combatEvents], combatEvents: [...state.combatEvents],
@@ -7124,6 +7339,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
raceId: state.raceId, raceId: state.raceId,
secondaryClassId: state.secondaryClassId, secondaryClassId: state.secondaryClassId,
}; };
for (const targetId of new Set(auraExpiration.expired.map((aura) => aura.targetId))) {
applyAuraHealthTransitionInWork(work, targetId, state.auras, activeAuras);
}
let autoAttackTargetId = state.autoAttackTargetId; let autoAttackTargetId = state.autoAttackTargetId;
let nextAutoAttackAt = state.nextAutoAttackAt; let nextAutoAttackAt = state.nextAutoAttackAt;
@@ -7839,9 +8057,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
activeCast: null, activeCast: null,
schoolLockedUntil: {}, schoolLockedUntil: {},
nextWoundAnimationAt: 0, nextWoundAnimationAt: 0,
}])); }]));
const auras = syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow());
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
state.talentModifiers,
state.gearStats,
state.raceId,
state.secondaryClassId,
auras,
);
return { return {
health: state.maxHealth, health: maxHealth,
maxHealth,
controlledUntil: 0, controlledUntil: 0,
controlMechanic: null, controlMechanic: null,
shield: 0, shield: 0,
@@ -7868,7 +8097,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
selectedTargetId: null, selectedTargetId: null,
selectedFriendlyActorId: null, selectedFriendlyActorId: null,
effects: [], effects: [],
auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow()), auras,
summons: [], summons: [],
areaEffects: [], areaEffects: [],
pendingGroundTarget: null, pendingGroundTarget: null,
@@ -7904,13 +8133,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return false; return false;
} }
const talentModifiers = characterStatModifiers(state.classId, state.level, result.ranks); const talentModifiers = characterStatModifiers(state.classId, state.level, result.ranks);
const maxHealth = talentAdjustedMaxHealth( const auras = syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, combatNow());
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId, state.classId,
state.level, state.level,
talentModifiers, talentModifiers,
state.gearStats, state.gearStats,
state.raceId, state.raceId,
state.secondaryClassId, state.secondaryClassId,
auras,
); );
const maxResource = talentAdjustedMaxResource( const maxResource = talentAdjustedMaxResource(
state.classId, state.classId,
@@ -7927,7 +8158,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth), health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
maxResource, maxResource,
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource), resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
auras: syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, combatNow()), auras,
feedback: nextFeedback("talent", "Conquest node rank allocated."), feedback: nextFeedback("talent", "Conquest node rank allocated."),
}); });
return true; return true;
@@ -7936,13 +8167,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
resetTalents: () => set((state) => { resetTalents: () => set((state) => {
const talentRanks = resetTalentRanks(state.talentRanks, state.classId); const talentRanks = resetTalentRanks(state.talentRanks, state.classId);
const talentModifiers = characterStatModifiers(state.classId, state.level, talentRanks); const talentModifiers = characterStatModifiers(state.classId, state.level, talentRanks);
const maxHealth = talentAdjustedMaxHealth( const auras = syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, combatNow());
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId, state.classId,
state.level, state.level,
talentModifiers, talentModifiers,
state.gearStats, state.gearStats,
state.raceId, state.raceId,
state.secondaryClassId, state.secondaryClassId,
auras,
); );
const maxResource = talentAdjustedMaxResource( const maxResource = talentAdjustedMaxResource(
state.classId, state.classId,
@@ -7959,7 +8192,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth), health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
maxResource, maxResource,
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource), resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
auras: syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, combatNow()), auras,
feedback: nextFeedback("talent", "Talent points reset."), feedback: nextFeedback("talent", "Talent points reset."),
}; };
}), }),
+4 -1
View File
@@ -5,6 +5,7 @@ import { useCombatStore } from "./combatStore";
import { respawnDefeatedPlayer } from "./deathRespawn"; import { respawnDefeatedPlayer } from "./deathRespawn";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
import { useWailingEncounterStore } from "./wailingCavernsEncounter"; import { useWailingEncounterStore } from "./wailingCavernsEncounter";
import { MANA_POOL_MULTIPLIER } from "./wotlkStats";
describe("death respawn", () => { describe("death respawn", () => {
beforeEach(() => { beforeEach(() => {
@@ -24,7 +25,9 @@ describe("death respawn", () => {
useCombatStore.getState().selectMob("respawn-pack:a"); useCombatStore.getState().selectMob("respawn-pack:a");
useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000); useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000);
expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true); expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true);
expect(useCombatStore.getState().resource).toBe(startingResource - Math.round(startingResource * 0.15)); expect(useCombatStore.getState().resource).toBe(
startingResource - Math.round(startingResource / MANA_POOL_MULTIPLIER * 0.15),
);
const revisionBefore = useGameStore.getState().resetRevision; const revisionBefore = useGameStore.getState().resetRevision;
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 }); useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
+51
View File
@@ -77,6 +77,57 @@ describe("generated dungeon campaign catalog", () => {
} }
}); });
it("runs Wailing Caverns with the complete server population and authored presentation", () => {
const wailing = requireDungeonDefinition("wailing-caverns");
const runtimePopulation = wailing.staticSpawns.length
+ wailing.roamingPacks.reduce((total, pack) => total + pack.members.length, 0);
expect(runtimePopulation).toBeGreaterThan(250);
expect(wailing.staticSpawns.some((spawn) => spawn.id === "creature-18678")).toBe(true);
expect(wailing.staticSpawns.some((spawn) => spawn.id === "lady-anacondra-spawn")).toBe(true);
expect(wailing.roamingPacks.some((pack) => pack.id === "kresh-patrol")).toBe(true);
expect(wailing.entities["entry-3636"]).toMatchObject({
name: "Deviate Ravager",
combat: { serverEntry: 3636 },
visual: {
model: { url: "/assets/creatures/wailing-caverns/deviate-ravager-animated.glb" },
},
});
});
it("never replaces a generated dungeon with a smaller live population", () => {
for (const source of generated.definitions) {
const runtime = requireDungeonDefinition(source.id);
const sourcePopulation = source.staticSpawns.length
+ source.roamingPacks.reduce((total, pack) => total + pack.members.length, 0);
const runtimePopulation = runtime.staticSpawns.length
+ runtime.roamingPacks.reduce((total, pack) => total + pack.members.length, 0);
expect(runtimePopulation, source.id).toBeGreaterThanOrEqual(sourcePopulation);
}
});
it("preserves source-side random rare spawns without making rare bosses mandatory", () => {
const chanceSpawns = generated.definitions.flatMap((dungeon) => [
...dungeon.staticSpawns.filter((spawn) => "spawnChance" in spawn),
...dungeon.roamingPacks.filter((pack) => "spawnChance" in pack),
]) as unknown as ReadonlyArray<{ readonly spawnChance: number }>;
const optionalBosses = generated.definitions.flatMap((dungeon) => (
dungeon.objectiveOrder.filter((objective) => "optional" in objective && objective.optional)
));
expect(chanceSpawns).toHaveLength(24);
expect(chanceSpawns.every((spawn) => spawn.spawnChance > 0 && spawn.spawnChance < 1)).toBe(true);
expect(optionalBosses.map((boss) => boss.name)).toEqual([
"Earthcaller Halmgar",
"Ragglesnout",
"Crystal Fang",
"Spirestone Battle Lord",
"Spirestone Butcher",
"Meshlok the Harvester",
]);
});
it("reuses each complete optimized map package for dungeons and Manastorms", () => { it("reuses each complete optimized map package for dungeons and Manastorms", () => {
const packagesByMap = new Map( const packagesByMap = new Map(
manastormPackages.packages.map((assetPackage) => [assetPackage.mapId, assetPackage]), manastormPackages.packages.map((assetPackage) => [assetPackage.mapId, assetPackage]),
+42 -16
View File
@@ -25,30 +25,56 @@ function normalizedName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
} }
/** Keep authored/server-backed WC combat; use generated kits only for unmapped fixtures. */ /** Keep authored WC presentation and encounters while restoring its complete server population. */
function enrichAuthoredDefinition( function enrichAuthoredDefinition(
authored: DungeonDefinition, authored: DungeonDefinition,
generated: DungeonDefinition | undefined, generated: DungeonDefinition | undefined,
): DungeonDefinition { ): DungeonDefinition {
if (!generated) return authored; if (!generated) return authored;
const generatedByName = new Map( const authoredByName = new Map(
Object.values(generated.entities).map((entity) => [normalizedName(entity.name), entity]), Object.values(authored.entities).map((entity) => [normalizedName(entity.name), entity]),
);
const generatedEntities = Object.fromEntries(
Object.entries(generated.entities).filter(([, entity]) => entity.kind !== "boss").map(([id, entity]) => {
const authoredEntity = authoredByName.get(normalizedName(entity.name));
return [
id,
authoredEntity
? {
...entity,
visual: authoredEntity.visual,
...(authoredEntity.combat ? { combat: authoredEntity.combat } : {}),
}
: entity,
];
}),
);
const generatedBossEntityIds = new Set(
Object.entries(generated.entities)
.filter(([, entity]) => entity.kind === "boss")
.map(([id]) => id),
);
const authoredBossEntityIds = new Set(
Object.entries(authored.entities)
.filter(([, entity]) => entity.kind === "boss")
.map(([id]) => id),
); );
return { return {
...authored, ...authored,
entities: Object.fromEntries( entities: {
Object.entries(authored.entities).map(([id, entity]) => { ...generatedEntities,
const generatedEntity = generatedByName.get(normalizedName(entity.name)); ...authored.entities,
return [ },
id, staticSpawns: [
entity.combat ...generated.staticSpawns.filter((spawn) => !generatedBossEntityIds.has(spawn.entityId)),
? entity ...authored.staticSpawns,
: generatedEntity?.combat ],
? { ...entity, combat: generatedEntity.combat } roamingPacks: [
: entity, ...generated.roamingPacks,
]; ...authored.roamingPacks.filter((pack) => (
}), pack.members.some((member) => authoredBossEntityIds.has(member.entityId))
), )),
],
}; };
} }
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { dungeonSpawnSelected, selectDungeonSessionPopulation } from "./dungeonSpawnSelection";
describe("dungeon spawn selection", () => {
it("keeps ordinary spawns and honors source chance boundaries", () => {
expect(dungeonSpawnSelected("deadmines", 1, { id: "ordinary" })).toBe(true);
expect(dungeonSpawnSelected("deadmines", 1, { id: "never", spawnChance: 0 })).toBe(false);
expect(dungeonSpawnSelected("deadmines", 1, { id: "always", spawnChance: 1 })).toBe(true);
});
it("selects rare spawns deterministically within a dungeon session", () => {
const results = Array.from({ length: 100 }, (_, sessionRevision) => (
dungeonSpawnSelected("blackrock-spire", sessionRevision, {
id: "creature-43519",
spawnChance: 0.15,
})
));
expect(results.some(Boolean)).toBe(true);
expect(results.some((selected) => !selected)).toBe(true);
expect(results).toEqual(Array.from({ length: 100 }, (_, sessionRevision) => (
dungeonSpawnSelected("blackrock-spire", sessionRevision, {
id: "creature-43519",
spawnChance: 0.15,
})
)));
});
it("filters static and roaming source populations with the same session rule", () => {
const population = selectDungeonSessionPopulation("stockades", 4, [
{ id: "ordinary", entityId: "guard", position: [0, 0, 0] },
{ id: "removed", entityId: "rare", position: [1, 0, 0], spawnChance: 0 },
], [{
id: "removed-patrol",
name: "Rare Patrol",
speed: 1,
formationSource: "database-solo",
spawnChance: 0,
waypoints: [[0, 0, 0], [1, 0, 0]],
members: [{ id: "member", entityId: "rare", role: "leader", formationOffset: [0, 0, 0] }],
}]);
expect(population.staticSpawns.map((spawn) => spawn.id)).toEqual(["ordinary"]);
expect(population.roamingPacks).toEqual([]);
});
});
+46
View File
@@ -0,0 +1,46 @@
import type { MobPackDefinition, StaticMobSpawnDefinition } from "./mobPopulation";
interface ChanceSpawn {
readonly id: string;
readonly spawnChance?: number;
}
function stableUnit(seed: string): number {
let hash = 2_166_136_261;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 16_777_619);
}
return (hash >>> 0) / 4_294_967_296;
}
/** Reproduces source-side random despawn rules once per dungeon session. */
export function dungeonSpawnSelected(
dungeonId: string,
sessionRevision: number,
spawn: ChanceSpawn,
): boolean {
const chance = Math.max(0, Math.min(1, spawn.spawnChance ?? 1));
if (chance <= 0) return false;
if (chance >= 1) return true;
return stableUnit(`${dungeonId}:${sessionRevision}:${spawn.id}:initialize`) < chance;
}
export function selectDungeonSessionPopulation(
dungeonId: string,
sessionRevision: number,
staticSpawns: readonly StaticMobSpawnDefinition[],
roamingPacks: readonly MobPackDefinition[],
): {
readonly staticSpawns: readonly StaticMobSpawnDefinition[];
readonly roamingPacks: readonly MobPackDefinition[];
} {
return {
staticSpawns: staticSpawns.filter((spawn) => (
dungeonSpawnSelected(dungeonId, sessionRevision, spawn)
)),
roamingPacks: roamingPacks.filter((pack) => (
dungeonSpawnSelected(dungeonId, sessionRevision, pack)
)),
};
}
@@ -1,2 +1,2 @@
// Generated by npm run dungeon:catalog. Do not edit. // Generated by npm run dungeon:catalog. Do not edit.
export default "H4sIAAAAAAACCuy9a5PbyJUo+FcY3N24MxFkKs/J10lF3A/9GI+96x57W147NhwdN0AgWcURH7UgS2p1h/77xskESJAASbBISaWy/EHuQhLIzJPn/crfh+v8Piyyv4dyPVsth69hNFyvHss8DF//PsyzxUM2u1v+lC1n07DeDF8Pi8flXVgtxw+zhzCfLcOr+kH9W/Hf69VyOBqG5btZuVouwnLz1yx/m92F9fD1P4frMn91ly3Cq7uwDGW2CcWr6exd+Os8+xDKH9O3/rR4WJWbdf2ljlcW2TJbb1bl4rv1Omy/H18Y/Nsm8PtZ+WEwzebzSZa//ffhL7ygfPW43ITyh2yTzVd3w9cnv/zz43IzW4Tqx/Vast9Cudrc/7Aqww+rxWLGINGFL6DQE+NQBQKfm1x5ObV5BuAckiom0wkFtf/6mwjlP6/yt90L6fzpFiQPYT7f7uP3YbbOwzId4FCIV3/Oitnb9U9//b//o5htVuWrfD4Ly804fyzLsNy8ysqQjQ2+esg2+f34x1c/fv+H2Tysf4i/evWGvy2KSc6H+LDK79M3/xiyeSh/ypbjN5tVmd2FVzUKjN+vyrc1HqxfxXfGacqjn/74cTTMV+8Cf2j4+p+/13j1p4KhcZ8Vq/fTbHk3fhvCw3A0XGQPPKJU4xjXw9eO/9zMNjPGLcWou8k2s/zNQ/Z+uR6+BulHw3KVLWbLO8aR9fA16h1+16eRr8ow/Dg6WAPjwPvZshivN6v8bVaExjL0/jJMcxnQWoZXh6uQPRdRhKxYzJZh3ZjbngABYgsErg0C1XP299lsPlvejfPsXSiXjTXog2OgvTXIwzWgwcM1uJ5LKLPfVuU0LMdvy+xx3liBO4UI1AMKgD2XMJln+dtptrlfLcZFCA9NOND+KuweHFwLDhJaq+iLCY/zIltky93cTl4EASXxyRC4W64WoQx3zen9wfT6NCUq19q6gr6U+Lh8G5Zj5urzBhFG4j6+BNWCvjaHS9CXYmGxet+kA0B/CgFMCw29bh1CX2Jc51k5D5vxYsXyKZQfGssgf4IfqRYeKsa6g2XYnsv47XE+zcoye7ubHuWp6RF6sAPQlxBjucrfjtcPs7KBDXh4FLC3CtciCGNtizHixasowsPmvoERqA7oAlRzGbZDROn2YZjeOHG/mq8W2TJvQuIQG+gMZ2BG9dQFbEpmjPNFUzgenoRvzm9a0kGrFmvgL/Saf5GV2WOxWjZF88ndt7iCwRZXQNmbLdyF6awM4/w+Wy8aa6BTnKmtH0D7BPrSQ8HTL/ZFY4sScI8SWlzJmRYlmL4HsL7PNptQhmJ8n83nDUIw+oAQ9uhAtfUUT09mjZP5alWMp4/lMmsSgtF4YgnQYoug/JOXcB/m84gKJVtB5WYPEgfa0t5xQAsSZJ4sqNebkC3eZY/zTXN6cxkUOthBX23tcVmEcrK6a85uT5BCh7pq4cnq6nqevQvjh7DcA747sXvsIMWWbFJ9oZ+VebYps98asxs8tXt//uj70uFktcmWszxrzq1OSWXfVpHhydi/CPl9tszK5uz6Iqwj/WSVIBmL43k2+VDOlpv75iLMZchHT2cA67C5D2/fttigsaeI33aoA0+2WNlzMd6sFpO9+U/hf4Tx/vzgn6wMZI/5fVhms3FefnjY44CGTgFBn7eXlesNg7sZa8fr8SaU5b44oJOMsG0z6hYjtJfoh+PFqszWTd3Q+hML6BBHT9YKwjrPHsJ4WrLJ+lgu71fzpvvC2FOyuc0aFLUPpO9SHjd3WVmEAz+OcafYQwdlAj5dKFUreJgtl1netCCNO8Uf+jCpvlDY3IfxMvz62CQKZ0/Zji3OgOrpLJKnX+WP8/356dTm+xxBX8GYP86jH2k1HXcZDMafZNNtdxI82Z8WuXNax2q5twR/ESY492QtpSizt5v7rFwtD2jCSnmRtPL+yUZ89tt/r+bjZSgfJ8354RR/bFlOTj4VH7anMJ/d3W+Ws+VdcxV4ETJ0oWVfx9Ld47LY82RYecqx256c3JP9ee9mK/bmMGduzk8njqBluPmnkkF2v3yb3YfNeDUvxm9ny7ti1TBfLZz057R1BqWerDNMV+VdiAS5emzqTVbhKZWhhYy6pbxSX95YzrI5r4CZZH6fLR5mTX+CNfIEMFpHYp56JA+zTQQDM4bm7KcQQrWpwdKTjcctVZZhOg/5Zh8M9qQK117Ik1n0ZrZ8G8pNyO/H6/vVe/a47pZBeMCjoLmMNmbaU6t4KFd5KB7LbD6u44Rt/CxXv4XluAxZft90ftNnXsaqvFttNmE55mhQw/Pr6FMD5JfRsAjT2XLGCLGOobp2xHjWHbXbzDZz/vCb7cjg/2rF8+bTuzrsW/D3iUO1vz5kdTgzn2fr9Szfjyj/tCoiIhWb8f2HSTljJlrMptNZ/jjffPh7Vs6y5SatNq5tuSoX2XzvR82na4bPT9n6bdzNPLwL85+zZYxMAo2U/KUCA7tWftx+4U97X87eZbN5NpnNZ/Hb1Z9R33wowzos+SR4R78P56u8+u8O0Gzuw4L39peHsOTI6rwYhF8fQjGrKHL9uFhkJU/xBxafg819uXq8ux8cfGk0KMI0ZJvBbLMebDFkUK7YPBoNsmUx4FMabO7DgD1WHwbZfPYuiOFoOF9lxWx591NYr2N0dvjXMjxk5Wx5dziJEPz7x2V+n5WbUHxXhuy/srj6/6d+NmjvkBHrrlw9Lhl+/5skWUg+3mkKZeer+aqMAxkgyOFouAxZmQKfU/4PtPLjaJgtJhxS/jPDYO+1jCbas14zW244Js6nIQXix9HwPixm64f7UIbta+u3H36o3yTnc2IXSlrc9rn0ciL94Ret/jiK/tfIKrN5eyUF5s4X++/BaLiaTtdhw6iFI5SjsfvlIxPEJrAkWscvPG5Wj5u/RtdqpLnh//oe/nfOYUgjDGN+/MtoOJmHZbH/S4y/jAONH2YFY9C7sP9bHX9bj21/Hhf0wEt5Pys29+yklwy+tEXFmuoDv7S8SwpBvlpuVo9l/OhPqAcozeAHsgMAGoDGAWo3QEkDcDB4o5QeOBhoaQeAdjga/qRogMoPfgDpB0BqAA4HaGmAWsbX3iitB94OlHcD54a/cPJAxokfEVrvZuvHbL6j9erv0XA6m4cKHdMzcTdnxHgs58PXw1fpCyntYpv18Uqp8Y6Rvdp7L78P+dv142L4eqhyHbDwwVPg5A7IOMsjKAjKAHqy0oSM9KQgpRDB5zKTTmUZaJr4aeYc0/Hst8CakienrR5FRWR5Nw8/MKUOX2uPEiTznXw1n88SM6y3uH00llLu73Rv6PINd73e2Lf009zmUgbMwE+nmnIoJrqYkA+T4CUYJSeFJ3A4Vd5NrA7gC6cyaeS0cFY29g3a29aurTHKsvRrbROObxOu2ya0t2ktkAOTZSorKLM48Y6M9ACZs2C9MVY6RVIVqAiNmgLISY45wlRikXvT3KZlb0vvbeLxbeJ128T2Np0Bg3pqaALOZgEmEzMp0BaZM65QuZMFOqMmhSNHuTdGZnqSmcxM86CkySaNbaJEumCb6vg21XXbVO1tThRkVk9Ro8llkRszdQCoEVE56ae+KEwWMm/kVFqTaeNCRvkUpJv4acizsHeadMk29fFt6uu2qdvbhAnoaeb1BCl3ekpSBt6KRE2apibTjrSSTmbTSV5MvJ9M8hCmE5t7yieozD5t4gXbNMe3aa7bpulAWvBeTzJyJptOXI5IOMXcQjYxmpT0IE1AyjLjC5oUk4CTKRT5xNhpluUQfGOb2ttLTtMe36a9bpu2vU2cTmGKucNCSVdYC9KaScjcdCpzNIoXDwABZdDewkTDRCmcknSoJ9aDbmzTkL5km+74Nt1123TtbRYTkzkpMcszlfssA5qCk8HKoPzUoso1gQm59cFa1MFMNbqQT3xRKOX9hBrbtFrqC7ZJx7dJ122TOgRKkU0Kq5SWhUVLE2szdFmWBYdSGjOZBphgEYLNvAEMGnNJdopTNSWrPTW3qaS5hDb98W3667bp29skI0kGnILLc0usA2WB8mmhKAtqkod84oxRAbR3xoapDEZq8pR5p5XLpk2BYgxeIDfhuBYE12lB0KEFeYkTRUVArSaTqQrZNBRBeyomU3TTibQKCg1eFlPlpgi5lLLQMGXVAGHidttU3pBs06azyvvObR7XguA6LQi6tCCkXNHEkM5tXlCGAQk9eG8okNOQyaktzGSaBQpWaydVbjn7uZAhBN0UKJ7gEi0IjmtBcJ0WBB1akMmtd56NZT+xNndOEoSJC84GnE40WAw6gNUyoJxKJVFhwFxPMum1dQ2BgqBUF6e1jnTnNo9rQXCdFgQdWpCHPLg8965wBq2ZoCzAW2WsNQCFl+TJoIdM5Xk2LShoZ6yc5jK3KsiparAgrUBDW3dnztV9nMfVILhODYIONciyPjuZaFYQlC2mE1TW2rzIMlJTkkCWILOeFPrC5BkG67Wy2dTiVJJ32e440akO4rROkenc5nE1CK5Tg6BDDZITRThRbI9YpCxMZK4zm0uVGQ2UhRyDchonoShwQhhkPvFTlRVukk3l1DXVIPDuAokCx9UguE4Ngg41iELIZeGDnErjvXVocEJK596aqWK1wYQJTOR0YmkSfI4ZepPUQm08qN02lVW2Y5tOe9lNnMfVILhODYIONchQbomCUQSTzOQYiDcqyWSFdXnQhLrwCjGfZkZrK3NrIASvtcYJaIc7pLWku1gtEXZv87gaBNepQdChBjkqiGzmC/Jg0Ml8okEHLVU2MSqnTAPK3E+mboo+R6Nt5q3BgIVVJoep2m7TSOl1W9vzDrBbcB5Xg+A6NQg61CBVFASQ68Jq5VloThypSZ6bSeE8SI9agVSZy6koKGM/yqQwWTEtMJdu6nfeIUVWdZyms1Kprm3icTUIr1ODsEMN0hOZFZnxRqLSLF2UdZkzaprlpjAmI0eZx1BIZbydkp3oQmkDhZtOpkiyYaI Line truncated export default "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 Line truncated
@@ -1 +1 @@
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated {"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
+5
View File
@@ -72,6 +72,8 @@ export interface MobPackDefinition {
readonly formationSource: "database-solo" | "inferred-followers"; readonly formationSource: "database-solo" | "inferred-followers";
/** Trinity-style difficulty mask. Omitted legacy fixtures default to normal (1). */ /** Trinity-style difficulty mask. Omitted legacy fixtures default to normal (1). */
readonly spawnMask?: number; readonly spawnMask?: number;
/** Per-instance survival chance after source initialization scripts run. */
readonly spawnChance?: number;
readonly waypoints: readonly MobVector3[]; readonly waypoints: readonly MobVector3[];
readonly members: readonly MobPackMemberDefinition[]; readonly members: readonly MobPackMemberDefinition[];
} }
@@ -83,6 +85,8 @@ export interface StaticMobSpawnDefinition {
readonly yaw?: number; readonly yaw?: number;
/** Trinity-style difficulty mask. Omitted legacy fixtures default to normal (1). */ /** Trinity-style difficulty mask. Omitted legacy fixtures default to normal (1). */
readonly spawnMask?: number; readonly spawnMask?: number;
/** Per-instance survival chance after source initialization scripts run. */
readonly spawnChance?: number;
} }
export interface StaticMobSpawnCluster { export interface StaticMobSpawnCluster {
@@ -183,6 +187,7 @@ export function normalizeMobPopulationPaths(
anchor[2] - member.formationOffset[2], anchor[2] - member.formationOffset[2],
], ],
spawnMask: pack.spawnMask, spawnMask: pack.spawnMask,
spawnChance: pack.spawnChance,
})); }));
}); });
return { return {
+2 -1
View File
@@ -121,7 +121,8 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
}] : []; }] : [];
} }
const game = useGameStore.getState(); const game = useGameStore.getState();
const objectives = requireDungeonDefinition(game.activeDungeonId).objectiveOrder; const objectives = requireDungeonDefinition(game.activeDungeonId).objectiveOrder
.filter((objective) => !objective.optional);
if (game.activeDungeonId !== "wailing-caverns") return objectives; if (game.activeDungeonId !== "wailing-caverns") return objectives;
const phase = useWailingEncounterStore.getState().phase; const phase = useWailingEncounterStore.getState().phase;
return phase === "mutanus" || phase === "complete" return phase === "mutanus" || phase === "complete"
+7 -3
View File
@@ -9,7 +9,7 @@ import {
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog"; import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats"; import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats";
import { EMPTY_ITEM_STATS } from "./itemStats"; import { EMPTY_ITEM_STATS } from "./itemStats";
import { deriveCharacterStats } from "./wotlkStats"; import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
const RACE_MATRIX = { const RACE_MATRIX = {
"rom-human": ["rom-warrior", "rom-scout", "rom-rogue", "rom-mage", "rom-priest", "rom-knight"], "rom-human": ["rom-warrior", "rom-scout", "rom-rogue", "rom-mage", "rom-priest", "rom-knight"],
@@ -118,8 +118,12 @@ describe("generated RuneWaker player catalog", () => {
}); });
it("gives level-one healers native mana pools and imports their real spell costs", () => { it("gives level-one healers native mana pools and imports their real spell costs", () => {
expect(deriveCharacterStats("rom-priest", 1, EMPTY_ITEM_STATS, "rom-human").mana).toBe(103); expect(deriveCharacterStats("rom-priest", 1, EMPTY_ITEM_STATS, "rom-human").mana).toBe(
expect(deriveCharacterStats("rom-druid", 1, EMPTY_ITEM_STATS, "rom-elf").mana).toBe(86); 103 * MANA_POOL_MULTIPLIER,
);
expect(deriveCharacterStats("rom-druid", 1, EMPTY_ITEM_STATS, "rom-elf").mana).toBe(
86 * MANA_POOL_MULTIPLIER,
);
expect(generated.skills.find((ability) => ability.id === "rom-priest-general-1")?.costs).toEqual([ expect(generated.skills.find((ability) => ability.id === "rom-priest-general-1")?.costs).toEqual([
{ resource: "mana", amount: 12, percentage: false }, { resource: "mana", amount: 12, percentage: false },
]); ]);
+2 -1
View File
@@ -7,6 +7,7 @@ import {
deriveCharacterStats, deriveCharacterStats,
hastedDuration, hastedDuration,
healthFromTotalStamina, healthFromTotalStamina,
MANA_POOL_MULTIPLIER,
ratingMultiplier, ratingMultiplier,
ratingPerPercent, ratingPerPercent,
} from "./wotlkStats"; } from "./wotlkStats";
@@ -50,7 +51,7 @@ describe("WoW 3.3.5a character stats", () => {
spellPower: 7, spellPower: 7,
mp5: 4, mp5: 4,
}); });
expect(mageGeared.mana - mageBase.mana).toBe(150); expect(mageGeared.mana - mageBase.mana).toBe(150 * MANA_POOL_MULTIPLIER);
expect(mageGeared.spellPower).toBe(7); expect(mageGeared.spellPower).toBe(7);
expect(mageGeared.spellCritPercent).toBeGreaterThan(mageBase.spellCritPercent); expect(mageGeared.spellCritPercent).toBeGreaterThan(mageBase.spellCritPercent);
expect(mageGeared.manaPer5).toBeGreaterThan(mageBase.manaPer5 + 4); expect(mageGeared.manaPer5).toBeGreaterThan(mageBase.manaPer5 + 4);
+3 -1
View File
@@ -105,6 +105,8 @@ interface CombatRatingsData {
const BASE_STATS = baseStatsData as BaseStatsData; const BASE_STATS = baseStatsData as BaseStatsData;
const COMBAT_RATINGS = combatRatingsData as CombatRatingsData; const COMBAT_RATINGS = combatRatingsData as CombatRatingsData;
/** Healer Man's larger combat pool; imported Wrath costs keep their pre-boost baseline. */
export const MANA_POOL_MULTIPLIER = 3;
const CLASS_DBC = Object.fromEntries( const CLASS_DBC = Object.fromEntries(
CLASSES.map((entry) => [entry.id, classById(baseClassFor(entry.id)).dbcId]), CLASSES.map((entry) => [entry.id, classById(baseClassFor(entry.id)).dbcId]),
) as Record<ClassId, number>; ) as Record<ClassId, number>;
@@ -279,7 +281,7 @@ export function deriveCharacterStats(
modifiers.maxHealth, modifiers.maxHealth,
); );
const mana = classUsesMana(classId) const mana = classUsesMana(classId)
? percentAdjusted( ? MANA_POOL_MULTIPLIER * percentAdjusted(
resources.mana resources.mana
+ (romBase ? intellect + spirit * 5 : manaFromTotalIntellect(intellect)) + (romBase ? intellect + spirit * 5 : manaFromTotalIntellect(intellect))
+ gear.mana, + gear.mana,
+11 -2
View File
@@ -51,6 +51,7 @@ import { useCombatStore } from "../game/combatStore";
import { numericFrameRateLimit } from "../game/frameRateSettings"; import { numericFrameRateLimit } from "../game/frameRateSettings";
import { useDocumentVisibility } from "../platform/useDocumentVisibility"; import { useDocumentVisibility } from "../platform/useDocumentVisibility";
import { CappedFrameLoop } from "./CappedFrameLoop"; import { CappedFrameLoop } from "./CappedFrameLoop";
import { selectDungeonSessionPopulation } from "../game/dungeonSpawnSelection";
export function GameScene() { export function GameScene() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId); const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -86,16 +87,24 @@ export function GameScene() {
: { entities: dungeon.entities, staticSpawns: dungeon.staticSpawns }, : { entities: dungeon.entities, staticSpawns: dungeon.staticSpawns },
[dungeon, gameMode, manastormEncounter], [dungeon, gameMode, manastormEncounter],
); );
const roamingPacks = manastormStageRoamingPacks( const difficultyRoamingPacks = manastormStageRoamingPacks(
dungeon.roamingPacks.filter((pack) => spawnMatchesDifficulty(pack.spawnMask, difficultyMask)), dungeon.roamingPacks.filter((pack) => spawnMatchesDifficulty(pack.spawnMask, difficultyMask)),
gameMode === "manastorm" ? manastormEncounter : null, gameMode === "manastorm" ? manastormEncounter : null,
); );
const difficultySpawns = stagePopulation.staticSpawns.filter( const difficultySpawns = stagePopulation.staticSpawns.filter(
(spawn) => spawnMatchesDifficulty(spawn.spawnMask, difficultyMask), (spawn) => spawnMatchesDifficulty(spawn.spawnMask, difficultyMask),
); );
const staticSpawns = activeDungeonId === "wailing-caverns" && gameMode === "dungeon" const phasedStaticSpawns = activeDungeonId === "wailing-caverns" && gameMode === "dungeon"
? wailingStaticSpawnsForPhase(difficultySpawns, wailingPhase) ? wailingStaticSpawnsForPhase(difficultySpawns, wailingPhase)
: difficultySpawns; : difficultySpawns;
const sessionPopulation = selectDungeonSessionPopulation(
activeDungeonId,
sessionRevision,
phasedStaticSpawns,
difficultyRoamingPacks,
);
const staticSpawns = sessionPopulation.staticSpawns;
const roamingPacks = sessionPopulation.roamingPacks;
const paused = useGameStore((state) => state.paused); const paused = useGameStore((state) => state.paused);
const mapOpen = useGameStore((state) => state.mapOpen); const mapOpen = useGameStore((state) => state.mapOpen);
const companionOpen = useGameStore((state) => state.companionOpen); const companionOpen = useGameStore((state) => state.companionOpen);
+1 -1
View File
@@ -1426,7 +1426,7 @@ export function PartyPopulation({
&& wailingPhase !== "mutanus" && wailingPhase !== "mutanus"
&& wailingPhase !== "complete" && wailingPhase !== "complete"
? dungeon.objectiveOrder.filter((objective) => !objective.eventOnly) ? dungeon.objectiveOrder.filter((objective) => !objective.eventOnly)
: dungeon.objectiveOrder : dungeon.objectiveOrder.filter((objective) => !objective.optional)
: []; : [];
const assets = useMemo( const assets = useMemo(
() => manastormPackage () => manastormPackage
+6 -9
View File
@@ -1,6 +1,6 @@
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber"; import { useThree } from "@react-three/fiber";
import { useCallback, useEffect, type ReactNode } from "react"; import { useCallback, type ReactNode } from "react";
import type { WebGLRenderer } from "three"; import type { WebGLRenderer } from "three";
import { KTX2Loader, type GLTFLoader } from "three-stdlib"; import { KTX2Loader, type GLTFLoader } from "three-stdlib";
import { resolveContentUrl } from "../content/contentManager"; import { resolveContentUrl } from "../content/contentManager";
@@ -20,15 +20,12 @@ function ktx2LoaderFor(renderer: WebGLRenderer): KTX2Loader {
return loader; return loader;
} }
/** Owns the decoder worker pool for one Canvas and releases it on renderer teardown. */ /**
* Marks the part of a Canvas that shares its GLTF decoder. KTX2Loader instances
* intentionally live with their weakly referenced renderer: disposing one from
* React cleanup can abort a GLB that is still decoding or cached by useGLTF.
*/
export function GameGltfLoaderLifecycle({ children }: { readonly children: ReactNode }) { export function GameGltfLoaderLifecycle({ children }: { readonly children: ReactNode }) {
const renderer = useThree((state) => state.gl);
useEffect(() => () => {
const loader = ktx2Loaders.get(renderer);
if (!loader) return;
loader.dispose();
ktx2Loaders.delete(renderer);
}, [renderer]);
return children; return children;
} }
+45
View File
@@ -3327,6 +3327,51 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
.arena-world, .arena-world,
.arena-world > div { position: absolute; inset: 0; } .arena-world > div { position: absolute; inset: 0; }
.arena-render-fallback {
display: grid;
place-content: center;
gap: .4rem;
padding: 2rem;
color: #e8eee4;
text-align: center;
background: radial-gradient(circle at center, #17251f, #07100d 68%);
}
.arena-render-fallback small { max-width: 32rem; color: #b9c4b6; }
.arena-asset-status {
position: absolute;
z-index: 3;
top: 50%;
left: 50%;
display: grid;
width: min(24rem, calc(100vw - 2rem));
gap: .45rem;
padding: .85rem 1rem;
border: 1px solid rgba(220, 190, 99, .5);
text-align: center;
background: rgba(6, 13, 10, .92);
transform: translate(-50%, -50%);
box-shadow: 0 1rem 4rem rgba(0, 0, 0, .45);
pointer-events: none;
}
.arena-asset-status > span {
overflow: hidden;
height: .32rem;
background: rgba(216, 185, 98, .16);
}
.arena-asset-status > span i {
display: block;
height: 100%;
background: #d8b962;
transition: width .15s ease-out;
}
.arena-asset-status small { color: #b9c4b6; }
.arena-asset-status--fallback { top: auto; bottom: 5.3rem; transform: translateX(-50%); }
.arena-match-header { .arena-match-header {
position: absolute; position: absolute;
top: .7rem; top: .7rem;
+23 -1
View File
@@ -7,7 +7,8 @@ import {
auraDefinitionIcon, auraDefinitionIcon,
auraDefinitionIsLongTerm, auraDefinitionIsLongTerm,
auraDescription, auraDescription,
} from "./AuraStrip"; groupAurasForDisplay,
} from "./auraDisplay";
function aura(overrides: Partial<AuraDefinition> = {}): AuraDefinition { function aura(overrides: Partial<AuraDefinition> = {}): AuraDefinition {
return { return {
@@ -71,4 +72,25 @@ describe("aura strip visibility", () => {
absorbs: [{ id: "test-absorb", amount: 250 }], absorbs: [{ id: "test-absorb", amount: 250 }],
}), 175))).toBe("Absorbs up to 175 incoming damage."); }), 175))).toBe("Absorbs up to 175 incoming damage.");
}); });
it("shows multi-effect and repeatedly sourced spell auras as one icon", () => {
const first = activeAura(aura({
id: "frostbolt-slow",
name: "Frostbolt (DBC 116, Aura 33)",
icon: "/assets/ui/spells/frostbolt.png",
}));
const second = {
...activeAura(aura({
id: "frostbolt-secondary",
name: "Frostbolt (DBC 116, Aura 118)",
icon: "/assets/ui/spells/frostbolt.png",
})),
instanceId: "second-frostbolt-aura",
sourceId: "second-mage",
appliedAt: 2_000,
expiresAt: 14_000,
};
expect(groupAurasForDisplay([first, second])).toEqual([second]);
});
}); });
+11 -42
View File
@@ -1,11 +1,17 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { ActiveAura, AuraDefinition, AuraDisposition } from "../game/combatAuras"; import type { AuraDisposition } from "../game/combatAuras";
import { useCombatStore } from "../game/combatStore"; import { useCombatStore } from "../game/combatStore";
import { formatTimedEffectTimer } from "../game/timedEffectDisplay"; import { formatTimedEffectTimer } from "../game/timedEffectDisplay";
import {
auraDefinitionDisplaysInStrip,
auraDefinitionIcon,
auraDefinitionIsLongTerm,
auraDescription,
auraDisplayName,
groupAurasForDisplay,
} from "./auraDisplay";
import type { StatusEffectInspectHandler, StatusEffectInspection } from "./statusEffectInspection"; import type { StatusEffectInspectHandler, StatusEffectInspection } from "./statusEffectInspection";
export const LONG_TERM_AURA_DURATION_MS = 60_000;
interface AuraStripProps { interface AuraStripProps {
readonly targetId: string; readonly targetId: string;
readonly targetIds?: readonly string[]; readonly targetIds?: readonly string[];
@@ -18,43 +24,6 @@ interface AuraStripProps {
readonly tooltipId?: string; readonly tooltipId?: string;
} }
export function auraDefinitionDisplaysInStrip(definition: AuraDefinition): boolean {
return definition.hideFromAuraStrip !== true;
}
export function auraDefinitionIcon(definition: AuraDefinition): string | null {
return definition.icon?.trim() || null;
}
export function auraDisplayName(definition: AuraDefinition): string {
return definition.name.replace(/\s+\((?:DBC\s+\d+,\s*)?Aura\s+\d+\)$/i, "").trim();
}
/** Permanent and one-minute-or-longer auras are persistent party-frame clutter. */
export function auraDefinitionIsLongTerm(definition: AuraDefinition): boolean {
return definition.durationMs === null || definition.durationMs >= LONG_TERM_AURA_DURATION_MS;
}
export function auraDescription(aura: ActiveAura): string {
const authored = aura.definition.description?.trim();
if (authored) return authored;
const absorb = aura.absorbs.reduce((total, pool) => total + pool.remaining, 0);
if (absorb > 0) return `Absorbs up to ${Math.ceil(absorb)} incoming damage.`;
if (aura.definition.controlImmunities?.length) {
return `Grants immunity to ${aura.definition.controlImmunities.join(", ")}.`;
}
if (aura.definition.modifiers?.length) {
return aura.definition.disposition === "buff"
? "Improves combat attributes while active."
: "Reduces combat effectiveness while active.";
}
if (aura.definition.procs?.length) return "Can trigger an additional effect during combat.";
return aura.definition.disposition === "buff"
? "A beneficial effect is active."
: "A harmful effect is active.";
}
export function AuraStrip({ export function AuraStrip({
targetId, targetId,
targetIds, targetIds,
@@ -69,14 +38,14 @@ export function AuraStrip({
const allAuras = useCombatStore((state) => state.auras); const allAuras = useCombatStore((state) => state.auras);
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
const requestedTargetIds = targetIds ?? [targetId]; const requestedTargetIds = targetIds ?? [targetId];
const auras = allAuras.filter((aura) => ( const auras = groupAurasForDisplay(allAuras.filter((aura) => (
requestedTargetIds.includes(aura.targetId) requestedTargetIds.includes(aura.targetId)
&& dispositions.includes(aura.definition.disposition) && dispositions.includes(aura.definition.disposition)
&& auraDefinitionDisplaysInStrip(aura.definition) && auraDefinitionDisplaysInStrip(aura.definition)
&& (!hideLongTerm || !auraDefinitionIsLongTerm(aura.definition)) && (!hideLongTerm || !auraDefinitionIsLongTerm(aura.definition))
&& (!showOnlyLongTerm || auraDefinitionIsLongTerm(aura.definition)) && (!showOnlyLongTerm || auraDefinitionIsLongTerm(aura.definition))
&& (aura.expiresAt === null || aura.expiresAt > now) && (aura.expiresAt === null || aura.expiresAt > now)
)); )));
useEffect(() => { useEffect(() => {
if (!auras.some((aura) => aura.expiresAt !== null)) return undefined; if (!auras.some((aura) => aura.expiresAt !== null)) return undefined;
+14 -5
View File
@@ -4,7 +4,7 @@ import { classById, raceById } from "../app/characterCatalog";
import { useShellStore } from "../app/shellStore"; import { useShellStore } from "../app/shellStore";
import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog"; import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog";
import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings"; import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
import { useCombatStore } from "../game/combatStore"; import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore";
import { respawnDefeatedPlayer } from "../game/deathRespawn"; import { respawnDefeatedPlayer } from "../game/deathRespawn";
import { requireDungeonDefinition } from "../game/dungeonRegistry"; import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { resurrectManastormSession } from "../game/manastormSession"; import { resurrectManastormSession } from "../game/manastormSession";
@@ -137,9 +137,12 @@ export function Hud() {
const resource = useCombatStore((state) => state.resource); const resource = useCombatStore((state) => state.resource);
const maxResource = useCombatStore((state) => state.maxResource); const maxResource = useCombatStore((state) => state.maxResource);
const resourcePools = useCombatStore((state) => state.resourcePools); const resourcePools = useCombatStore((state) => state.resourcePools);
const maxResourcePools = useCombatStore((state) => state.maxResourcePools);
const resourceName = useCombatStore((state) => state.resourceName); const resourceName = useCombatStore((state) => state.resourceName);
const abilities = useCombatStore((state) => state.abilities); const abilities = useCombatStore((state) => state.abilities);
const talentRanks = useCombatStore((state) => state.talentRanks); const talentRanks = useCombatStore((state) => state.talentRanks);
const talentModifiers = useCombatStore((state) => state.talentModifiers);
const auras = useCombatStore((state) => state.auras);
const actionBindings = useCombatStore((state) => state.actionBindings); const actionBindings = useCombatStore((state) => state.actionBindings);
const cooldowns = useCombatStore((state) => state.cooldowns); const cooldowns = useCombatStore((state) => state.cooldowns);
const globalCooldownEndsAt = useCombatStore((state) => state.globalCooldownEndsAt); const globalCooldownEndsAt = useCombatStore((state) => state.globalCooldownEndsAt);
@@ -190,11 +193,17 @@ export function Hud() {
const personalRemaining = Math.max(0, (cooldowns[ability.id] ?? 0) - clock); const personalRemaining = Math.max(0, (cooldowns[ability.id] ?? 0) - clock);
const globalRemaining = Math.max(0, globalCooldownEndsAt - clock); const globalRemaining = Math.max(0, globalCooldownEndsAt - clock);
const remaining = Math.max(personalRemaining, globalRemaining); const remaining = Math.max(personalRemaining, globalRemaining);
const abilityCosts = ability.costs?.length ? ability.costs : [ability.cost]; const abilityCosts = resolvedAbilityCosts(ability, talentModifiers, {
classId,
maxHealth,
maxResource,
maxResourcePools,
auras,
});
const cannotPay = abilityCosts.some((cost) => cost.resource === "health" const cannotPay = abilityCosts.some((cost) => cost.resource === "health"
? health <= cost.amount ? health <= cost.amount
: (resourcePools[cost.resource] ?? (cost.resource === resourceTone ? resource : 0)) < cost.amount); : (cost.resource === resourceTone ? resource : resourcePools[cost.resource] ?? 0) < cost.amount);
const displayCost = abilityCosts[0] ?? ability.cost; const displayCost = abilityCosts[0] ?? { resource: ability.cost.resource, amount: 0 };
const item: ActionBarItem = { const item: ActionBarItem = {
id: ability.id, id: ability.id,
name: ability.name, name: ability.name,
@@ -211,7 +220,7 @@ export function Hud() {
target: ability.target, target: ability.target,
}; };
return [control, item]; return [control, item];
})) as ActionBarItems, [abilities, actionBindings, actionLayer, activeCast, classColor, clock, cooldowns, globalCooldownEndsAt, health, level, resource, resourceName, resourcePools, resourceTone, talentRanks]); })) as ActionBarItems, [abilities, actionBindings, actionLayer, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
const castBinding = (control: (typeof ACTION_CONTROLS)[number]) => useCombatStore.getState().castActionBinding( const castBinding = (control: (typeof ACTION_CONTROLS)[number]) => useCombatStore.getState().castActionBinding(
useGameStore.getState().actionLayer, useGameStore.getState().actionLayer,
+9 -11
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "../game/aggro"; import { PLAYER_AGGRO_ID } from "../game/aggro";
import type { ActiveAura, AuraDefinition } from "../game/combatAuras"; import type { ActiveAura, AuraDefinition } from "../game/combatAuras";
import { auraDisplaysInPlayerStatusPanel } from "./PlayerStatusPanel"; import { auraDisplaysInPlayerStatusPanel } from "./auraDisplay";
function activeAura( function activeAura(
targetId: string, targetId: string,
@@ -31,18 +31,16 @@ function activeAura(
describe("top-right status visibility", () => { describe("top-right status visibility", () => {
const now = 20_000; const now = 20_000;
const partyTargets = new Set(["party-1"]);
it("shows every player aura and centralizes long-term party auras", () => { it("shows every active player aura without including party-member copies", () => {
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 15_000), now, partyTargets)).toBe(true); expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 15_000), now)).toBe(true);
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", 60_000), now, partyTargets)).toBe(true); expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", 60_000), now)).toBe(false);
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", null), now, partyTargets)).toBe(true); expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", null), now)).toBe(false);
}); });
it("leaves short party effects on party frames and rejects unrelated, hidden, or expired auras", () => { it("rejects unrelated, hidden, or expired auras", () => {
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", 30_000), now, partyTargets)).toBe(false); expect(auraDisplaysInPlayerStatusPanel(activeAura("stranger", 60_000), now)).toBe(false);
expect(auraDisplaysInPlayerStatusPanel(activeAura("stranger", 60_000), now, partyTargets)).toBe(false); expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 60_000, 70_000, true), now)).toBe(false);
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 60_000, 70_000, true), now, partyTargets)).toBe(false); expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 5_000, 15_000), now)).toBe(false);
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 5_000, 15_000), now, partyTargets)).toBe(false);
}); });
}); });
+7 -51
View File
@@ -1,17 +1,15 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { abilityById } from "../game/abilityCatalog"; import { abilityById } from "../game/abilityCatalog";
import { PLAYER_AGGRO_ID } from "../game/aggro"; import { PLAYER_AGGRO_ID } from "../game/aggro";
import type { ActiveAura } from "../game/combatAuras";
import { useCombatStore } from "../game/combatStore"; import { useCombatStore } from "../game/combatStore";
import { usePartyStore } from "../game/partyStore";
import { formatTimedEffectTimer, timedEffectRemainingMs } from "../game/timedEffectDisplay"; import { formatTimedEffectTimer, timedEffectRemainingMs } from "../game/timedEffectDisplay";
import { AuraStrip } from "./AuraStrip";
import { import {
AuraStrip, auraDisplaysInPlayerStatusPanel,
auraDefinitionDisplaysInStrip,
auraDefinitionIsLongTerm,
auraDescription, auraDescription,
auraDisplayName, auraDisplayName,
} from "./AuraStrip"; groupAurasForDisplay,
} from "./auraDisplay";
import { TimedEffectStrip } from "./TimedEffectStrip"; import { TimedEffectStrip } from "./TimedEffectStrip";
import { import {
timedEffectDescription, timedEffectDescription,
@@ -25,34 +23,16 @@ function statusLabel(disposition: StatusEffectInspection["disposition"]): string
return disposition === "buff" ? "Buff" : "Debuff"; return disposition === "buff" ? "Buff" : "Debuff";
} }
export function auraDisplaysInPlayerStatusPanel(
aura: ActiveAura,
now: number,
partyTargetIds: ReadonlySet<string>,
): boolean {
const belongsInPanel = aura.targetId === PLAYER_AGGRO_ID
|| (partyTargetIds.has(aura.targetId) && auraDefinitionIsLongTerm(aura.definition));
return belongsInPanel
&& auraDefinitionDisplaysInStrip(aura.definition)
&& (aura.expiresAt === null || aura.expiresAt > now);
}
export function PlayerStatusPanel({ now }: { readonly now: number }) { export function PlayerStatusPanel({ now }: { readonly now: number }) {
const auras = useCombatStore((state) => state.auras); const auras = useCombatStore((state) => state.auras);
const effects = useCombatStore((state) => state.effects); const effects = useCombatStore((state) => state.effects);
const shield = useCombatStore((state) => state.shield); const shield = useCombatStore((state) => state.shield);
const shieldExpiresAt = useCombatStore((state) => state.shieldExpiresAt); const shieldExpiresAt = useCombatStore((state) => state.shieldExpiresAt);
const partyMembers = usePartyStore((state) => state.members);
const [inspected, setInspected] = useState<StatusEffectInspection | null>(null); const [inspected, setInspected] = useState<StatusEffectInspection | null>(null);
const partyAuraTargetIds = partyMembers.map((member) => member.id); const activeAuras = groupAurasForDisplay(auras.filter((aura) => (
const partyAuraTargetIdSet = new Set(partyAuraTargetIds); auraDisplaysInPlayerStatusPanel(aura, now)
const partyAuraTargetLabels = Object.fromEntries( )));
partyMembers.map((member) => [member.id, member.name]),
);
const activeAuras = auras.filter((aura) => (
auraDisplaysInPlayerStatusPanel(aura, now, partyAuraTargetIdSet)
));
const activeEffects = effects.filter((effect) => ( const activeEffects = effects.filter((effect) => (
PLAYER_EFFECT_TARGETS.includes(effect.targetId) PLAYER_EFFECT_TARGETS.includes(effect.targetId)
&& timedEffectRemainingMs(effect, now) > 0 && timedEffectRemainingMs(effect, now) > 0
@@ -152,18 +132,6 @@ export function PlayerStatusPanel({ now }: { readonly now: number }) {
onInspect={setInspected} onInspect={setInspected}
tooltipId={TOOLTIP_ID} tooltipId={TOOLTIP_ID}
/> />
{partyAuraTargetIds.length ? (
<AuraStrip
targetId={partyAuraTargetIds[0]}
targetIds={partyAuraTargetIds}
dispositions={["buff"]}
className="player-status-panel__strip"
showOnlyLongTerm
targetLabels={partyAuraTargetLabels}
onInspect={setInspected}
tooltipId={TOOLTIP_ID}
/>
) : null}
<TimedEffectStrip <TimedEffectStrip
targetId={null} targetId={null}
targetIds={PLAYER_EFFECT_TARGETS} targetIds={PLAYER_EFFECT_TARGETS}
@@ -189,18 +157,6 @@ export function PlayerStatusPanel({ now }: { readonly now: number }) {
onInspect={setInspected} onInspect={setInspected}
tooltipId={TOOLTIP_ID} tooltipId={TOOLTIP_ID}
/> />
{partyAuraTargetIds.length ? (
<AuraStrip
targetId={partyAuraTargetIds[0]}
targetIds={partyAuraTargetIds}
dispositions={["debuff"]}
className="player-status-panel__strip"
showOnlyLongTerm
targetLabels={partyAuraTargetLabels}
onInspect={setInspected}
tooltipId={TOOLTIP_ID}
/>
) : null}
<TimedEffectStrip <TimedEffectStrip
targetId={null} targetId={null}
targetIds={PLAYER_EFFECT_TARGETS} targetIds={PLAYER_EFFECT_TARGETS}
+85
View File
@@ -0,0 +1,85 @@
import { PLAYER_AGGRO_ID } from "../game/aggro";
import type { ActiveAura, AuraDefinition } from "../game/combatAuras";
export const LONG_TERM_AURA_DURATION_MS = 60_000;
export function auraDefinitionDisplaysInStrip(definition: AuraDefinition): boolean {
return definition.hideFromAuraStrip !== true;
}
export function auraDefinitionIcon(definition: AuraDefinition): string | null {
return definition.icon?.trim() || null;
}
export function auraDisplayName(definition: AuraDefinition): string {
return definition.name.replace(/\s+\((?:DBC\s+\d+,\s*)?Aura\s+\d+\)$/i, "").trim();
}
function auraDisplayGroupKey(aura: ActiveAura): string {
return [
aura.targetId,
aura.definition.disposition,
auraDisplayName(aura.definition).toLowerCase(),
auraDefinitionIcon(aura.definition) ?? "",
].join("\u0000");
}
function auraDisplayExpiry(aura: ActiveAura): number {
return aura.expiresAt ?? Number.POSITIVE_INFINITY;
}
/** Collapses a spell's multiple DBC aura effects and repeated sources into one HUD icon. */
export function groupAurasForDisplay(auras: readonly ActiveAura[]): ActiveAura[] {
const grouped = new Map<string, ActiveAura>();
for (const aura of auras) {
const key = auraDisplayGroupKey(aura);
const existing = grouped.get(key);
if (!existing) {
grouped.set(key, aura);
continue;
}
const representative = auraDisplayExpiry(aura) > auraDisplayExpiry(existing)
|| (auraDisplayExpiry(aura) === auraDisplayExpiry(existing) && aura.appliedAt > existing.appliedAt)
? aura
: existing;
const stacks = Math.max(existing.stacks, aura.stacks);
grouped.set(key, representative.stacks === stacks
? representative
: { ...representative, stacks });
}
return [...grouped.values()];
}
/** Permanent and one-minute-or-longer auras are persistent party-frame clutter. */
export function auraDefinitionIsLongTerm(definition: AuraDefinition): boolean {
return definition.durationMs === null || definition.durationMs >= LONG_TERM_AURA_DURATION_MS;
}
export function auraDescription(aura: ActiveAura): string {
const authored = aura.definition.description?.trim();
if (authored) return authored;
const absorb = aura.absorbs.reduce((total, pool) => total + pool.remaining, 0);
if (absorb > 0) return `Absorbs up to ${Math.ceil(absorb)} incoming damage.`;
if (aura.definition.controlImmunities?.length) {
return `Grants immunity to ${aura.definition.controlImmunities.join(", ")}.`;
}
if (aura.definition.modifiers?.length) {
return aura.definition.disposition === "buff"
? "Improves combat attributes while active."
: "Reduces combat effectiveness while active.";
}
if (aura.definition.procs?.length) return "Can trigger an additional effect during combat.";
return aura.definition.disposition === "buff"
? "A beneficial effect is active."
: "A harmful effect is active.";
}
export function auraDisplaysInPlayerStatusPanel(
aura: ActiveAura,
now: number,
): boolean {
return aura.targetId === PLAYER_AGGRO_ID
&& auraDefinitionDisplaysInStrip(aura.definition)
&& (aura.expiresAt === null || aura.expiresAt > now);
}