Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
699fc858c9 | ||
|
|
d9bebb4e81 |
No files matched your search
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"status": "green",
|
||||
"scope": "HealerMan WoW five-player campaign",
|
||||
"scope": "HealerMan WoW five-player combat and animation coverage",
|
||||
"summary": {
|
||||
"dungeons": 54,
|
||||
"mobs": 1031,
|
||||
"bosses": 253,
|
||||
"attacks": 2953,
|
||||
"specialAttacks": 1669,
|
||||
"attacks": 3765,
|
||||
"specialAttacks": 2481,
|
||||
"nativeModels": 1266,
|
||||
"proceduralModels": 18,
|
||||
"uniqueNativeModels": 1166,
|
||||
"nativeStateFallbacks": 16
|
||||
"nativeStateFallbacks": 18
|
||||
},
|
||||
"documentedExceptions": [
|
||||
{
|
||||
@@ -47,6 +47,14 @@
|
||||
],
|
||||
"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",
|
||||
"state": "attack",
|
||||
@@ -56,7 +64,15 @@
|
||||
"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",
|
||||
"users": [
|
||||
"steamvault:entry-21694",
|
||||
@@ -170,8 +186,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 24,
|
||||
"bosses": 7,
|
||||
"attacks": 58,
|
||||
"specialAttacks": 27,
|
||||
"attacks": 65,
|
||||
"specialAttacks": 34,
|
||||
"nativeModels": 31,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -182,8 +198,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 6,
|
||||
"bosses": 5,
|
||||
"attacks": 32,
|
||||
"specialAttacks": 21,
|
||||
"attacks": 42,
|
||||
"specialAttacks": 31,
|
||||
"nativeModels": 11,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -194,8 +210,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 15,
|
||||
"bosses": 7,
|
||||
"attacks": 48,
|
||||
"specialAttacks": 26,
|
||||
"attacks": 55,
|
||||
"specialAttacks": 33,
|
||||
"nativeModels": 22,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -218,8 +234,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 31,
|
||||
"bosses": 7,
|
||||
"attacks": 78,
|
||||
"specialAttacks": 40,
|
||||
"attacks": 87,
|
||||
"specialAttacks": 49,
|
||||
"nativeModels": 38,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -230,8 +246,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 21,
|
||||
"bosses": 6,
|
||||
"attacks": 58,
|
||||
"specialAttacks": 31,
|
||||
"attacks": 65,
|
||||
"specialAttacks": 38,
|
||||
"nativeModels": 27,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -242,8 +258,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 31,
|
||||
"bosses": 7,
|
||||
"attacks": 85,
|
||||
"specialAttacks": 47,
|
||||
"attacks": 104,
|
||||
"specialAttacks": 66,
|
||||
"nativeModels": 38,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -254,8 +270,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 27,
|
||||
"bosses": 4,
|
||||
"attacks": 68,
|
||||
"specialAttacks": 37,
|
||||
"attacks": 82,
|
||||
"specialAttacks": 51,
|
||||
"nativeModels": 31,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -266,8 +282,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 29,
|
||||
"bosses": 4,
|
||||
"attacks": 86,
|
||||
"specialAttacks": 53,
|
||||
"attacks": 93,
|
||||
"specialAttacks": 60,
|
||||
"nativeModels": 33,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -278,8 +294,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 19,
|
||||
"bosses": 6,
|
||||
"attacks": 55,
|
||||
"specialAttacks": 30,
|
||||
"attacks": 63,
|
||||
"specialAttacks": 38,
|
||||
"nativeModels": 25,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -290,8 +306,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 32,
|
||||
"bosses": 5,
|
||||
"attacks": 89,
|
||||
"specialAttacks": 52,
|
||||
"attacks": 107,
|
||||
"specialAttacks": 70,
|
||||
"nativeModels": 37,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -302,8 +318,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 16,
|
||||
"bosses": 5,
|
||||
"attacks": 53,
|
||||
"specialAttacks": 32,
|
||||
"attacks": 69,
|
||||
"specialAttacks": 48,
|
||||
"nativeModels": 21,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -314,8 +330,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 63,
|
||||
"bosses": 15,
|
||||
"attacks": 210,
|
||||
"specialAttacks": 132,
|
||||
"attacks": 273,
|
||||
"specialAttacks": 195,
|
||||
"nativeModels": 78,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -326,8 +342,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 48,
|
||||
"bosses": 13,
|
||||
"attacks": 145,
|
||||
"specialAttacks": 84,
|
||||
"attacks": 182,
|
||||
"specialAttacks": 121,
|
||||
"nativeModels": 61,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -338,8 +354,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 30,
|
||||
"bosses": 8,
|
||||
"attacks": 115,
|
||||
"specialAttacks": 77,
|
||||
"attacks": 150,
|
||||
"specialAttacks": 112,
|
||||
"nativeModels": 38,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -350,8 +366,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 41,
|
||||
"bosses": 9,
|
||||
"attacks": 131,
|
||||
"specialAttacks": 81,
|
||||
"attacks": 176,
|
||||
"specialAttacks": 126,
|
||||
"nativeModels": 50,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -362,8 +378,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 25,
|
||||
"bosses": 8,
|
||||
"attacks": 83,
|
||||
"specialAttacks": 50,
|
||||
"attacks": 95,
|
||||
"specialAttacks": 62,
|
||||
"nativeModels": 33,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -374,8 +390,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 7,
|
||||
"bosses": 4,
|
||||
"attacks": 21,
|
||||
"specialAttacks": 10,
|
||||
"attacks": 23,
|
||||
"specialAttacks": 12,
|
||||
"nativeModels": 11,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -386,8 +402,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 63,
|
||||
"bosses": 12,
|
||||
"attacks": 184,
|
||||
"specialAttacks": 109,
|
||||
"attacks": 242,
|
||||
"specialAttacks": 167,
|
||||
"nativeModels": 75,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -398,8 +414,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 29,
|
||||
"bosses": 3,
|
||||
"attacks": 74,
|
||||
"specialAttacks": 42,
|
||||
"attacks": 92,
|
||||
"specialAttacks": 60,
|
||||
"nativeModels": 32,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -410,8 +426,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 14,
|
||||
"bosses": 3,
|
||||
"attacks": 44,
|
||||
"specialAttacks": 27,
|
||||
"attacks": 53,
|
||||
"specialAttacks": 36,
|
||||
"nativeModels": 17,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -422,8 +438,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 10,
|
||||
"bosses": 2,
|
||||
"attacks": 36,
|
||||
"specialAttacks": 24,
|
||||
"attacks": 41,
|
||||
"specialAttacks": 29,
|
||||
"nativeModels": 12,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -434,8 +450,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 14,
|
||||
"bosses": 3,
|
||||
"attacks": 50,
|
||||
"specialAttacks": 33,
|
||||
"attacks": 63,
|
||||
"specialAttacks": 46,
|
||||
"nativeModels": 17,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -446,8 +462,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 18,
|
||||
"bosses": 4,
|
||||
"attacks": 59,
|
||||
"specialAttacks": 37,
|
||||
"attacks": 80,
|
||||
"specialAttacks": 58,
|
||||
"nativeModels": 22,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -458,8 +474,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 18,
|
||||
"bosses": 3,
|
||||
"attacks": 53,
|
||||
"specialAttacks": 32,
|
||||
"attacks": 60,
|
||||
"specialAttacks": 39,
|
||||
"nativeModels": 21,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -470,8 +486,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 25,
|
||||
"bosses": 4,
|
||||
"attacks": 73,
|
||||
"specialAttacks": 44,
|
||||
"attacks": 105,
|
||||
"specialAttacks": 76,
|
||||
"nativeModels": 29,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -482,8 +498,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 24,
|
||||
"bosses": 5,
|
||||
"attacks": 67,
|
||||
"specialAttacks": 38,
|
||||
"attacks": 85,
|
||||
"specialAttacks": 56,
|
||||
"nativeModels": 29,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -494,8 +510,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 14,
|
||||
"bosses": 3,
|
||||
"attacks": 45,
|
||||
"specialAttacks": 28,
|
||||
"attacks": 69,
|
||||
"specialAttacks": 52,
|
||||
"nativeModels": 17,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -506,8 +522,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 18,
|
||||
"bosses": 4,
|
||||
"attacks": 46,
|
||||
"specialAttacks": 24,
|
||||
"attacks": 67,
|
||||
"specialAttacks": 45,
|
||||
"nativeModels": 22,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -518,8 +534,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 14,
|
||||
"bosses": 2,
|
||||
"attacks": 39,
|
||||
"specialAttacks": 23,
|
||||
"attacks": 50,
|
||||
"specialAttacks": 34,
|
||||
"nativeModels": 16,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -530,8 +546,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 11,
|
||||
"bosses": 3,
|
||||
"attacks": 33,
|
||||
"specialAttacks": 19,
|
||||
"attacks": 46,
|
||||
"specialAttacks": 32,
|
||||
"nativeModels": 14,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -542,8 +558,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 12,
|
||||
"bosses": 2,
|
||||
"attacks": 30,
|
||||
"specialAttacks": 16,
|
||||
"attacks": 44,
|
||||
"specialAttacks": 30,
|
||||
"nativeModels": 14,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -554,8 +570,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 15,
|
||||
"bosses": 3,
|
||||
"attacks": 33,
|
||||
"specialAttacks": 15,
|
||||
"attacks": 57,
|
||||
"specialAttacks": 39,
|
||||
"nativeModels": 18,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -566,8 +582,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 4,
|
||||
"bosses": 3,
|
||||
"attacks": 20,
|
||||
"specialAttacks": 13,
|
||||
"attacks": 25,
|
||||
"specialAttacks": 18,
|
||||
"nativeModels": 7,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -578,8 +594,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 26,
|
||||
"bosses": 3,
|
||||
"attacks": 45,
|
||||
"specialAttacks": 16,
|
||||
"attacks": 53,
|
||||
"specialAttacks": 24,
|
||||
"nativeModels": 29,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -590,8 +606,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 19,
|
||||
"bosses": 3,
|
||||
"attacks": 35,
|
||||
"specialAttacks": 13,
|
||||
"attacks": 44,
|
||||
"specialAttacks": 22,
|
||||
"nativeModels": 22,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -602,8 +618,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 13,
|
||||
"bosses": 4,
|
||||
"attacks": 32,
|
||||
"specialAttacks": 15,
|
||||
"attacks": 44,
|
||||
"specialAttacks": 27,
|
||||
"nativeModels": 17,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -614,8 +630,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 21,
|
||||
"bosses": 5,
|
||||
"attacks": 50,
|
||||
"specialAttacks": 24,
|
||||
"attacks": 61,
|
||||
"specialAttacks": 35,
|
||||
"nativeModels": 26,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -626,8 +642,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 8,
|
||||
"bosses": 4,
|
||||
"attacks": 27,
|
||||
"specialAttacks": 15,
|
||||
"attacks": 33,
|
||||
"specialAttacks": 21,
|
||||
"nativeModels": 12,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -638,8 +654,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 16,
|
||||
"bosses": 4,
|
||||
"attacks": 41,
|
||||
"specialAttacks": 21,
|
||||
"attacks": 44,
|
||||
"specialAttacks": 24,
|
||||
"nativeModels": 20,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -650,8 +666,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 13,
|
||||
"bosses": 4,
|
||||
"attacks": 32,
|
||||
"specialAttacks": 15,
|
||||
"attacks": 42,
|
||||
"specialAttacks": 25,
|
||||
"nativeModels": 16,
|
||||
"proceduralModels": 1,
|
||||
"status": "green"
|
||||
@@ -662,8 +678,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 18,
|
||||
"bosses": 4,
|
||||
"attacks": 38,
|
||||
"specialAttacks": 16,
|
||||
"attacks": 45,
|
||||
"specialAttacks": 23,
|
||||
"nativeModels": 22,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -674,8 +690,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 10,
|
||||
"bosses": 3,
|
||||
"attacks": 25,
|
||||
"specialAttacks": 12,
|
||||
"attacks": 30,
|
||||
"specialAttacks": 17,
|
||||
"nativeModels": 13,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -686,8 +702,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 16,
|
||||
"bosses": 4,
|
||||
"attacks": 35,
|
||||
"specialAttacks": 15,
|
||||
"attacks": 49,
|
||||
"specialAttacks": 29,
|
||||
"nativeModels": 20,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -698,8 +714,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 15,
|
||||
"bosses": 5,
|
||||
"attacks": 43,
|
||||
"specialAttacks": 23,
|
||||
"attacks": 53,
|
||||
"specialAttacks": 33,
|
||||
"nativeModels": 20,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -710,8 +726,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 2,
|
||||
"bosses": 3,
|
||||
"attacks": 19,
|
||||
"specialAttacks": 14,
|
||||
"attacks": 24,
|
||||
"specialAttacks": 19,
|
||||
"nativeModels": 5,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -722,8 +738,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 19,
|
||||
"bosses": 5,
|
||||
"attacks": 43,
|
||||
"specialAttacks": 19,
|
||||
"attacks": 60,
|
||||
"specialAttacks": 36,
|
||||
"nativeModels": 24,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -734,8 +750,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 11,
|
||||
"bosses": 2,
|
||||
"attacks": 21,
|
||||
"specialAttacks": 8,
|
||||
"attacks": 23,
|
||||
"specialAttacks": 10,
|
||||
"nativeModels": 13,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -746,8 +762,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 0,
|
||||
"bosses": 5,
|
||||
"attacks": 22,
|
||||
"specialAttacks": 17,
|
||||
"attacks": 88,
|
||||
"specialAttacks": 83,
|
||||
"nativeModels": 5,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -758,8 +774,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 27,
|
||||
"bosses": 3,
|
||||
"attacks": 43,
|
||||
"specialAttacks": 13,
|
||||
"attacks": 49,
|
||||
"specialAttacks": 19,
|
||||
"nativeModels": 30,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
@@ -770,8 +786,8 @@
|
||||
"source": "azerothcore",
|
||||
"mobs": 8,
|
||||
"bosses": 2,
|
||||
"attacks": 26,
|
||||
"specialAttacks": 16,
|
||||
"attacks": 38,
|
||||
"specialAttacks": 28,
|
||||
"nativeModels": 10,
|
||||
"proceduralModels": 0,
|
||||
"status": "green"
|
||||
|
||||
@@ -1,64 +1,66 @@
|
||||
# WoW dungeon content report
|
||||
# WoW dungeon combat and animation report
|
||||
|
||||
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 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| Shadowfang Keep | 24 | 7 | 58 | 27 | 31 | 0 | green |
|
||||
| Stormwind Stockade | 6 | 5 | 32 | 21 | 11 | 0 | green |
|
||||
| The Deadmines | 15 | 7 | 48 | 26 | 22 | 0 | green |
|
||||
| Shadowfang Keep | 24 | 7 | 65 | 34 | 31 | 0 | green |
|
||||
| Stormwind Stockade | 6 | 5 | 42 | 31 | 11 | 0 | green |
|
||||
| The Deadmines | 15 | 7 | 55 | 33 | 22 | 0 | green |
|
||||
| Wailing Caverns | 12 | 8 | 51 | 31 | 15 | 5 | green |
|
||||
| Razorfen Kraul | 31 | 7 | 78 | 40 | 38 | 0 | green |
|
||||
| Blackfathom Deeps | 21 | 6 | 58 | 31 | 27 | 0 | green |
|
||||
| Uldaman | 31 | 7 | 85 | 47 | 38 | 0 | green |
|
||||
| Gnomeregan | 27 | 4 | 68 | 37 | 31 | 0 | green |
|
||||
| Sunken Temple | 29 | 4 | 86 | 53 | 33 | 0 | green |
|
||||
| Razorfen Downs | 19 | 6 | 55 | 30 | 25 | 0 | green |
|
||||
| Scarlet Monastery | 32 | 5 | 89 | 52 | 37 | 0 | green |
|
||||
| Zul'Farrak | 16 | 5 | 53 | 32 | 21 | 0 | green |
|
||||
| Blackrock Spire | 63 | 15 | 210 | 132 | 78 | 0 | green |
|
||||
| Blackrock Depths | 48 | 13 | 145 | 84 | 61 | 0 | green |
|
||||
| Scholomance | 30 | 8 | 115 | 77 | 38 | 0 | green |
|
||||
| Stratholme | 41 | 9 | 131 | 81 | 50 | 0 | green |
|
||||
| Maraudon | 25 | 8 | 83 | 50 | 33 | 0 | green |
|
||||
| Ragefire Chasm | 7 | 4 | 21 | 10 | 11 | 0 | green |
|
||||
| Dire Maul | 63 | 12 | 184 | 109 | 75 | 0 | green |
|
||||
| Hellfire Citadel: The Shattered Halls | 29 | 3 | 74 | 42 | 32 | 0 | green |
|
||||
| Hellfire Citadel: The Blood Furnace | 14 | 3 | 44 | 27 | 17 | 0 | green |
|
||||
| Hellfire Citadel: Ramparts | 10 | 2 | 36 | 24 | 12 | 0 | green |
|
||||
| Coilfang: The Steamvault | 14 | 3 | 50 | 33 | 17 | 0 | green |
|
||||
| Coilfang: The Underbog | 18 | 4 | 59 | 37 | 22 | 0 | green |
|
||||
| Coilfang: The Slave Pens | 18 | 3 | 53 | 32 | 21 | 0 | green |
|
||||
| Tempest Keep: The Arcatraz | 25 | 4 | 73 | 44 | 29 | 0 | green |
|
||||
| Tempest Keep: The Botanica | 24 | 5 | 67 | 38 | 29 | 0 | green |
|
||||
| Tempest Keep: The Mechanar | 14 | 3 | 45 | 28 | 17 | 0 | green |
|
||||
| Auchindoun: Shadow Labyrinth | 18 | 4 | 46 | 24 | 22 | 0 | green |
|
||||
| Auchindoun: Sethekk Halls | 14 | 2 | 39 | 23 | 16 | 0 | green |
|
||||
| Auchindoun: Mana-Tombs | 11 | 3 | 33 | 19 | 14 | 0 | green |
|
||||
| Auchindoun: Auchenai Crypts | 12 | 2 | 30 | 16 | 14 | 0 | green |
|
||||
| Magister's Terrace | 15 | 3 | 33 | 15 | 18 | 0 | green |
|
||||
| The Black Morass | 4 | 3 | 20 | 13 | 7 | 0 | green |
|
||||
| The Escape From Durnholde | 26 | 3 | 45 | 16 | 29 | 0 | green |
|
||||
| Utgarde Keep | 19 | 3 | 35 | 13 | 22 | 0 | green |
|
||||
| Utgarde Pinnacle | 13 | 4 | 32 | 15 | 17 | 0 | green |
|
||||
| The Nexus | 21 | 5 | 50 | 24 | 26 | 0 | green |
|
||||
| The Oculus | 8 | 4 | 27 | 15 | 12 | 0 | green |
|
||||
| The Culling of Stratholme | 16 | 4 | 41 | 21 | 20 | 0 | green |
|
||||
| Halls of Stone | 13 | 4 | 32 | 15 | 16 | 1 | green |
|
||||
| Drak'Tharon Keep | 18 | 4 | 38 | 16 | 22 | 0 | green |
|
||||
| Azjol-Nerub | 10 | 3 | 25 | 12 | 13 | 0 | green |
|
||||
| Halls of Lightning | 16 | 4 | 35 | 15 | 20 | 0 | green |
|
||||
| Gundrak | 15 | 5 | 43 | 23 | 20 | 0 | green |
|
||||
| Violet Hold | 2 | 3 | 19 | 14 | 5 | 0 | green |
|
||||
| Ahn'kahet: The Old Kingdom | 19 | 5 | 43 | 19 | 24 | 0 | green |
|
||||
| The Forge of Souls | 11 | 2 | 21 | 8 | 13 | 0 | green |
|
||||
| Trial of the Champion | 0 | 5 | 22 | 17 | 5 | 0 | green |
|
||||
| Pit of Saron | 27 | 3 | 43 | 13 | 30 | 0 | green |
|
||||
| Halls of Reflection | 8 | 2 | 26 | 16 | 10 | 0 | green |
|
||||
| Razorfen Kraul | 31 | 7 | 87 | 49 | 38 | 0 | green |
|
||||
| Blackfathom Deeps | 21 | 6 | 65 | 38 | 27 | 0 | green |
|
||||
| Uldaman | 31 | 7 | 104 | 66 | 38 | 0 | green |
|
||||
| Gnomeregan | 27 | 4 | 82 | 51 | 31 | 0 | green |
|
||||
| Sunken Temple | 29 | 4 | 93 | 60 | 33 | 0 | green |
|
||||
| Razorfen Downs | 19 | 6 | 63 | 38 | 25 | 0 | green |
|
||||
| Scarlet Monastery | 32 | 5 | 107 | 70 | 37 | 0 | green |
|
||||
| Zul'Farrak | 16 | 5 | 69 | 48 | 21 | 0 | green |
|
||||
| Blackrock Spire | 63 | 15 | 273 | 195 | 78 | 0 | green |
|
||||
| Blackrock Depths | 48 | 13 | 182 | 121 | 61 | 0 | green |
|
||||
| Scholomance | 30 | 8 | 150 | 112 | 38 | 0 | green |
|
||||
| Stratholme | 41 | 9 | 176 | 126 | 50 | 0 | green |
|
||||
| Maraudon | 25 | 8 | 95 | 62 | 33 | 0 | green |
|
||||
| Ragefire Chasm | 7 | 4 | 23 | 12 | 11 | 0 | green |
|
||||
| Dire Maul | 63 | 12 | 242 | 167 | 75 | 0 | green |
|
||||
| Hellfire Citadel: The Shattered Halls | 29 | 3 | 92 | 60 | 32 | 0 | green |
|
||||
| Hellfire Citadel: The Blood Furnace | 14 | 3 | 53 | 36 | 17 | 0 | green |
|
||||
| Hellfire Citadel: Ramparts | 10 | 2 | 41 | 29 | 12 | 0 | green |
|
||||
| Coilfang: The Steamvault | 14 | 3 | 63 | 46 | 17 | 0 | green |
|
||||
| Coilfang: The Underbog | 18 | 4 | 80 | 58 | 22 | 0 | green |
|
||||
| Coilfang: The Slave Pens | 18 | 3 | 60 | 39 | 21 | 0 | green |
|
||||
| Tempest Keep: The Arcatraz | 25 | 4 | 105 | 76 | 29 | 0 | green |
|
||||
| Tempest Keep: The Botanica | 24 | 5 | 85 | 56 | 29 | 0 | green |
|
||||
| Tempest Keep: The Mechanar | 14 | 3 | 69 | 52 | 17 | 0 | green |
|
||||
| Auchindoun: Shadow Labyrinth | 18 | 4 | 67 | 45 | 22 | 0 | green |
|
||||
| Auchindoun: Sethekk Halls | 14 | 2 | 50 | 34 | 16 | 0 | green |
|
||||
| Auchindoun: Mana-Tombs | 11 | 3 | 46 | 32 | 14 | 0 | green |
|
||||
| Auchindoun: Auchenai Crypts | 12 | 2 | 44 | 30 | 14 | 0 | green |
|
||||
| Magister's Terrace | 15 | 3 | 57 | 39 | 18 | 0 | green |
|
||||
| The Black Morass | 4 | 3 | 25 | 18 | 7 | 0 | green |
|
||||
| The Escape From Durnholde | 26 | 3 | 53 | 24 | 29 | 0 | green |
|
||||
| Utgarde Keep | 19 | 3 | 44 | 22 | 22 | 0 | green |
|
||||
| Utgarde Pinnacle | 13 | 4 | 44 | 27 | 17 | 0 | green |
|
||||
| The Nexus | 21 | 5 | 61 | 35 | 26 | 0 | green |
|
||||
| The Oculus | 8 | 4 | 33 | 21 | 12 | 0 | green |
|
||||
| The Culling of Stratholme | 16 | 4 | 44 | 24 | 20 | 0 | green |
|
||||
| Halls of Stone | 13 | 4 | 42 | 25 | 16 | 1 | green |
|
||||
| Drak'Tharon Keep | 18 | 4 | 45 | 23 | 22 | 0 | green |
|
||||
| Azjol-Nerub | 10 | 3 | 30 | 17 | 13 | 0 | green |
|
||||
| Halls of Lightning | 16 | 4 | 49 | 29 | 20 | 0 | green |
|
||||
| Gundrak | 15 | 5 | 53 | 33 | 20 | 0 | green |
|
||||
| Violet Hold | 2 | 3 | 24 | 19 | 5 | 0 | green |
|
||||
| Ahn'kahet: The Old Kingdom | 19 | 5 | 60 | 36 | 24 | 0 | green |
|
||||
| The Forge of Souls | 11 | 2 | 23 | 10 | 13 | 0 | green |
|
||||
| Trial of the Champion | 0 | 5 | 88 | 83 | 5 | 0 | green |
|
||||
| Pit of Saron | 27 | 3 | 49 | 19 | 30 | 0 | green |
|
||||
| Halls of Reflection | 8 | 2 | 38 | 28 | 10 | 0 | green |
|
||||
| Tinkertech Showdown | 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 |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.18",
|
||||
"versionCode": 1018
|
||||
"versionName": "0.1.20",
|
||||
"versionCode": 1020
|
||||
}
|
||||
@@ -116,10 +116,12 @@ function reportMarkdown(report) {
|
||||
dungeon.proceduralModels, "|", dungeon.status, "|",
|
||||
].join(" "));
|
||||
return [
|
||||
"# WoW dungeon content report",
|
||||
"# WoW dungeon combat and animation report",
|
||||
"",
|
||||
"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
|
||||
+ " mobs, " + report.summary.bosses + " bosses, " + report.summary.attacks
|
||||
+ " executable attacks, and " + report.summary.specialAttacks + " special attacks/mechanics.",
|
||||
@@ -177,6 +179,10 @@ for (const dungeon of definitions) {
|
||||
let specialAttacks = 0;
|
||||
let nativeModels = 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 (!bosses.length) dungeonIssues.push("has no bosses.");
|
||||
@@ -203,7 +209,7 @@ for (const dungeon of definitions) {
|
||||
attacks += entityAttacks.length;
|
||||
const specials = entityAttacks.filter((attack) => attack.id !== "basic-attack");
|
||||
specialAttacks += specials.length;
|
||||
if (entity.kind === "boss" && !specials.length) {
|
||||
if (entity.kind === "boss" && !specials.length && !optionalBossEntityIds.has(entityId)) {
|
||||
const exceptionKey = dungeon.id + ":" + entityId;
|
||||
if (!basicOnlyBossExceptions.has(exceptionKey)) {
|
||||
dungeonIssues.push(entityId + " has no boss mechanic beyond its basic attack.");
|
||||
@@ -297,7 +303,7 @@ const summary = dungeons.reduce((result, dungeon) => ({
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
status: issues.length ? "blocked" : "green",
|
||||
scope: "HealerMan WoW five-player campaign",
|
||||
scope: "HealerMan WoW five-player combat and animation coverage",
|
||||
summary,
|
||||
documentedExceptions,
|
||||
animationFallbacks,
|
||||
@@ -317,4 +323,4 @@ console.log(JSON.stringify({
|
||||
documentedExceptions: report.documentedExceptions,
|
||||
issues: report.issues,
|
||||
}, 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;
|
||||
}
|
||||
|
||||
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) {
|
||||
const seenIds = new Set();
|
||||
const seenSpellIds = new Set();
|
||||
@@ -1233,6 +1245,7 @@ async function compile() {
|
||||
const id = encounter.eventOnly
|
||||
? "mutanus-spawn"
|
||||
: candidate?.id ?? `encounter-${encounter.encounterId}`;
|
||||
const spawnChance = initializationSpawnChance(encounter.creatureId, azerothCoreWorldAi);
|
||||
usedBossSpawnIds.add(id);
|
||||
return {
|
||||
id,
|
||||
@@ -1240,6 +1253,7 @@ async function compile() {
|
||||
name: encounter.name,
|
||||
position,
|
||||
yaw: candidate?.yaw ?? 0,
|
||||
...(spawnChance < 1 ? { optional: true } : {}),
|
||||
...(encounter.eventOnly ? { eventOnly: true } : {}),
|
||||
};
|
||||
});
|
||||
@@ -1248,6 +1262,7 @@ async function compile() {
|
||||
const roamingPacks = [];
|
||||
for (const spawn of sourceSpawns) {
|
||||
const entry = Number(spawn.entityId.replace("entry-", ""));
|
||||
const spawnChance = initializationSpawnChance(entry, azerothCoreWorldAi);
|
||||
const isBoss = bossEntries.has(entry);
|
||||
const transformedPosition = mapPosition(spawn.position);
|
||||
if (!isBoss && spawn.waypoints?.length >= 2) {
|
||||
@@ -1269,6 +1284,7 @@ async function compile() {
|
||||
pathId: spawn.pathId,
|
||||
formationSource: "database-solo",
|
||||
spawnMask: spawn.spawnMask,
|
||||
...(spawnChance < 1 ? { spawnChance } : {}),
|
||||
waypoints,
|
||||
members: [{
|
||||
id: "member",
|
||||
@@ -1286,6 +1302,7 @@ async function compile() {
|
||||
position: transformedPosition,
|
||||
yaw: spawn.yaw,
|
||||
spawnMask: spawn.spawnMask,
|
||||
...(spawnChance < 1 ? { spawnChance } : {}),
|
||||
});
|
||||
}
|
||||
for (const boss of bossObjectives) {
|
||||
@@ -1337,10 +1354,11 @@ async function compile() {
|
||||
...roamingPacks.flatMap((pack) => pack.waypoints),
|
||||
];
|
||||
const bounds = finiteBounds(allPoints);
|
||||
const bosses = bossObjectives.map(({ id, name, position, eventOnly }) => ({
|
||||
const bosses = bossObjectives.map(({ id, name, position, optional, eventOnly }) => ({
|
||||
id,
|
||||
name,
|
||||
position,
|
||||
...(optional ? { optional: true } : {}),
|
||||
...(eventOnly ? { eventOnly: true } : {}),
|
||||
}));
|
||||
const databaseKind = recipe.clientBuild === "3.3.5a-Epoch" || draft?.spawns?.length
|
||||
|
||||
@@ -36,6 +36,8 @@ export function PartyBridge() {
|
||||
character.categoryId ?? "wow",
|
||||
);
|
||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||
combat.synchronizeActors();
|
||||
combat.applyDefaultPartyBuffs();
|
||||
resetPartyRuntimeClock();
|
||||
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
|
||||
|
||||
@@ -44,6 +46,8 @@ export function PartyBridge() {
|
||||
const combat = useCombatStore.getState();
|
||||
party.syncLevel(level);
|
||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||
combat.synchronizeActors();
|
||||
combat.applyDefaultPartyBuffs();
|
||||
}, [level]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,6 +56,8 @@ export function PartyBridge() {
|
||||
const combat = useCombatStore.getState();
|
||||
usePartyStore.getState().resetCombat(combat.level, combat.health);
|
||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||
combat.synchronizeActors();
|
||||
combat.applyDefaultPartyBuffs();
|
||||
resetPartyRuntimeClock();
|
||||
}, [resetRevision]);
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ describe("named aura combat runtime", () => {
|
||||
|
||||
it("applies and expires an imported named player buff", () => {
|
||||
const now = 10_000;
|
||||
const baseHealth = useCombatStore.getState().health;
|
||||
const baseMaxHealth = useCombatStore.getState().maxHealth;
|
||||
const result = useCombatStore.getState().castAbility(
|
||||
"wow335-priest-power-word-fortitude",
|
||||
undefined,
|
||||
@@ -46,9 +48,49 @@ describe("named aura combat runtime", () => {
|
||||
))?.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);
|
||||
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", () => {
|
||||
|
||||
@@ -27,6 +27,18 @@ describe("authentic combat resources", () => {
|
||||
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", () => {
|
||||
const rage = normalizeActorResourcePool("rage", 20, 100);
|
||||
const runic = normalizeActorResourcePool("runic-power", 20, 100);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "./combatActors";
|
||||
|
||||
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 interface CombatResourceAdvanceRules {
|
||||
@@ -24,6 +25,9 @@ export function advanceCombatResourcePool(
|
||||
if (pool.id === "mana" && rules.now - pool.lastSpentAt < MANA_FIVE_SECOND_RULE_MS) {
|
||||
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) {
|
||||
rate = pool.id === "rage" ? -3 : -2.5;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { TALENT_NODES, talentNodesForTree } from "./talentCatalog";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { abilitiesForClass, defaultActionBarForClass } from "./abilityCatalog";
|
||||
import { defaultActionBindings } from "./actionBindings";
|
||||
import { deriveCharacterStats } from "./wotlkStats";
|
||||
import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("combat store", () => {
|
||||
const first = useCombatStore.getState().castSlot(0, undefined, 1000);
|
||||
expect(first).toMatchObject({ ok: true, abilityId: "mage-frostbolt", damage: 0 });
|
||||
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().activeCast).toMatchObject({ completesAt: 2500, mode: "cast" });
|
||||
|
||||
@@ -93,7 +93,9 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
|
||||
useCombatStore.getState().tick(0.1, 2500);
|
||||
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().mobs.ravager.engaged).toBe(true);
|
||||
});
|
||||
@@ -104,7 +106,10 @@ describe("combat store", () => {
|
||||
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 * 0.14) });
|
||||
expect(cost).toEqual({
|
||||
resource: "mana",
|
||||
amount: Math.round(initial.maxResource / MANA_POOL_MULTIPLIER * 0.14),
|
||||
});
|
||||
registerTarget("mana-target", [10, 0, 0]);
|
||||
|
||||
useCombatStore.setState({
|
||||
@@ -208,8 +213,8 @@ describe("combat store", () => {
|
||||
const chronomancerBase = deriveCharacterStats("chronomancer", 1, EMPTY_ITEM_STATS, "human");
|
||||
useCombatStore.getState().initializeCharacter({ classId: "chronomancer", level: 1 });
|
||||
expect(useCombatStore.getState()).toMatchObject({
|
||||
resource: Math.round(chronomancerBase.mana * 1.12),
|
||||
maxResource: Math.round(chronomancerBase.mana * 1.12),
|
||||
resource: Math.round(chronomancerBase.mana / MANA_POOL_MULTIPLIER * 1.12) * MANA_POOL_MULTIPLIER,
|
||||
maxResource: Math.round(chronomancerBase.mana / MANA_POOL_MULTIPLIER * 1.12) * MANA_POOL_MULTIPLIER,
|
||||
});
|
||||
|
||||
useCombatStore.getState().initializeCharacter({ classId: "barbarian", level: 10 });
|
||||
@@ -1065,7 +1070,9 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true);
|
||||
const boltCompletion = useCombatStore.getState().activeCast!.completesAt;
|
||||
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", () => {
|
||||
|
||||
+244
-19
@@ -155,6 +155,8 @@ import {
|
||||
deriveCharacterStats,
|
||||
equipmentAttackPower,
|
||||
hastedDuration,
|
||||
healthFromTotalStamina,
|
||||
MANA_POOL_MULTIPLIER,
|
||||
ratingPercent,
|
||||
type CharacterStats,
|
||||
type WotlkAttackKind,
|
||||
@@ -673,6 +675,8 @@ export interface CombatState {
|
||||
confirmGroundTarget: (position?: CombatPosition, now?: number) => CastResult;
|
||||
cancelGroundTarget: () => 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. */
|
||||
commitCompanionActors: (members: readonly import("./partyStore").PartyMember[]) => void;
|
||||
castSlot: (index: number, origin?: CombatPosition, now?: number) => CastResult;
|
||||
@@ -1517,7 +1521,10 @@ export function resolvedAbilityCosts(
|
||||
: cost.resource === activeResourceType
|
||||
? state.maxResource
|
||||
: state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource);
|
||||
const baseAmount = cost.percentage ? maximum * cost.amount / 100 : cost.amount;
|
||||
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(
|
||||
baseAmount,
|
||||
modifiers,
|
||||
@@ -1889,11 +1896,16 @@ function auraDefinitionForAbility(
|
||||
ability: AbilityDefinition,
|
||||
durationMs = definition.durationMs,
|
||||
): AuraDefinition {
|
||||
const defaultPartyBuffFamily = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase());
|
||||
return {
|
||||
...definition,
|
||||
description: definition.description ?? ability.description,
|
||||
icon: definition.icon ?? ability.icon,
|
||||
durationMs,
|
||||
...(defaultPartyBuffFamily ? {
|
||||
uniqueBySource: false,
|
||||
exclusiveGroup: `default-party-buff:${defaultPartyBuffFamily}`,
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1916,14 +1928,16 @@ function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): Cha
|
||||
"all-resistance",
|
||||
"magical-defense",
|
||||
);
|
||||
const stamina = primary(base.stamina, "stamina");
|
||||
const staminaHealth = healthFromTotalStamina(stamina) - healthFromTotalStamina(base.stamina);
|
||||
return {
|
||||
...base,
|
||||
strength: primary(base.strength, "strength"),
|
||||
agility: primary(base.agility, "agility", "dexterity"),
|
||||
stamina: primary(base.stamina, "stamina"),
|
||||
stamina,
|
||||
intellect: primary(base.intellect, "intellect"),
|
||||
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"),
|
||||
attackPower: stat(base.attackPower, "attack-power", "physical-attack"),
|
||||
rangedAttackPower: stat(base.rangedAttackPower, "ranged-attack-power", "physical-attack"),
|
||||
@@ -1951,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(
|
||||
current: readonly ActiveAura[],
|
||||
abilities: readonly AbilityDefinition[],
|
||||
@@ -2199,13 +2260,15 @@ function grantMobExperience(
|
||||
progression.levelsGained += result.levelsGained;
|
||||
if (result.levelsGained > 0) {
|
||||
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,
|
||||
result.level,
|
||||
work.talentModifiers,
|
||||
work.gearStats,
|
||||
work.raceId,
|
||||
work.secondaryClassId,
|
||||
work.auras,
|
||||
);
|
||||
progression.health = progression.maxHealth;
|
||||
work.health = progression.health;
|
||||
@@ -2218,7 +2281,6 @@ function grantMobExperience(
|
||||
work.raceId,
|
||||
work.secondaryClassId,
|
||||
);
|
||||
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow());
|
||||
}
|
||||
return scaledAward;
|
||||
}
|
||||
@@ -2722,12 +2784,14 @@ function executeTriggeredSpellInWork(
|
||||
const recipient = effect.recipient === "caster"
|
||||
? sourceId
|
||||
: targetId ?? sourceId;
|
||||
work.auras = [...applyCombatAura(work.auras, {
|
||||
const previousAuras = work.auras;
|
||||
work.auras = [...applyCombatAura(previousAuras, {
|
||||
definition: auraDefinitionForAbility(effect.aura, ability),
|
||||
sourceId,
|
||||
targetId: recipient,
|
||||
now,
|
||||
}).auras];
|
||||
applyAuraHealthTransitionInWork(work, recipient, previousAuras, work.auras);
|
||||
if (effect.control && work.mobs[recipient]) {
|
||||
work.mobs[recipient] = applyStatus(work.mobs[recipient], {
|
||||
kind: "status",
|
||||
@@ -2966,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 {
|
||||
const actor = ensureCompanionActorInWork(work, id);
|
||||
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
||||
@@ -3268,13 +3373,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-")
|
||||
? resolvedEquipmentStats(state.equipment, state.inventory, context)
|
||||
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||
const maxHealth = talentAdjustedMaxHealth(
|
||||
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||
state.classId,
|
||||
state.level,
|
||||
state.talentModifiers,
|
||||
gearStats,
|
||||
state.raceId,
|
||||
state.secondaryClassId,
|
||||
state.auras,
|
||||
);
|
||||
const maxResource = talentAdjustedMaxResource(
|
||||
state.classId,
|
||||
@@ -3542,13 +3648,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
role: state.gearRole,
|
||||
level: state.level,
|
||||
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||
const maxHealth = talentAdjustedMaxHealth(
|
||||
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||
state.classId,
|
||||
state.level,
|
||||
state.talentModifiers,
|
||||
gearStats,
|
||||
state.raceId,
|
||||
state.secondaryClassId,
|
||||
state.auras,
|
||||
);
|
||||
const maxResource = talentAdjustedMaxResource(
|
||||
state.classId,
|
||||
@@ -3591,13 +3698,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
role: state.gearRole,
|
||||
level: state.level,
|
||||
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||
const maxHealth = talentAdjustedMaxHealth(
|
||||
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||
state.classId,
|
||||
state.level,
|
||||
state.talentModifiers,
|
||||
gearStats,
|
||||
state.raceId,
|
||||
state.secondaryClassId,
|
||||
state.auras,
|
||||
);
|
||||
const maxResource = talentAdjustedMaxResource(
|
||||
state.classId,
|
||||
@@ -3814,6 +3922,59 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
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) => {
|
||||
let actors: Readonly<Record<string, CombatActorState>> = {};
|
||||
set((state) => {
|
||||
@@ -4760,12 +4921,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
? affectedMobIds
|
||||
: [targetId ?? PLAYER_AURA_ENTITY_ID];
|
||||
for (const recipientId of recipients) {
|
||||
work.auras = [...applyCombatAura(work.auras, {
|
||||
const previousAuras = work.auras;
|
||||
work.auras = [...applyCombatAura(previousAuras, {
|
||||
definition: auraDefinition,
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: recipientId,
|
||||
now,
|
||||
}).auras];
|
||||
applyAuraHealthTransitionInWork(work, recipientId, previousAuras, work.auras);
|
||||
if (auraDefinition.controlImmunities?.length) {
|
||||
if (
|
||||
recipientId === PLAYER_AURA_ENTITY_ID
|
||||
@@ -5733,8 +5896,49 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
targetId: targetActorId,
|
||||
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({
|
||||
auras: result.auras,
|
||||
health,
|
||||
maxHealth,
|
||||
actors,
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: now,
|
||||
kind: "aura",
|
||||
@@ -5749,6 +5953,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
critical: false,
|
||||
}),
|
||||
});
|
||||
if (companionActor) mirrorCompanionActor(companionActor);
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -7028,6 +7233,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const delta = clamp(Number.isFinite(deltaSeconds) ? deltaSeconds : 0, 0, 1);
|
||||
const resourceProfile = resourceProfileForClass(state.classId);
|
||||
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(
|
||||
(resourceProfile.type === "rage" || resourceProfile.type === "runic-power")
|
||||
? 0
|
||||
@@ -7037,7 +7244,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
state.talentModifiers,
|
||||
"resource-regeneration-percent",
|
||||
);
|
||||
const playerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
|
||||
const playerAuras = aurasForEntity(activeAuras, PLAYER_AURA_ENTITY_ID);
|
||||
const regeneration = resolveAuraResourceValue(
|
||||
resolveAuraResourceValue(baseRegeneration, playerAuras, resourceProfile.type, "regeneration"),
|
||||
playerAuras,
|
||||
@@ -7097,7 +7304,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
abilities: state.abilities,
|
||||
mobs: { ...state.mobs },
|
||||
effects: [],
|
||||
auras: [...expireAuras(state.auras, now).active],
|
||||
auras: activeAuras,
|
||||
summons: state.summons.filter((summon) => summon.expiresAt > now),
|
||||
areaEffects: state.areaEffects.filter((effect) => effect.expiresAt > now),
|
||||
combatEvents: [...state.combatEvents],
|
||||
@@ -7132,6 +7339,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
raceId: state.raceId,
|
||||
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 nextAutoAttackAt = state.nextAutoAttackAt;
|
||||
@@ -7847,9 +8057,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
activeCast: null,
|
||||
schoolLockedUntil: {},
|
||||
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 {
|
||||
health: state.maxHealth,
|
||||
health: maxHealth,
|
||||
maxHealth,
|
||||
controlledUntil: 0,
|
||||
controlMechanic: null,
|
||||
shield: 0,
|
||||
@@ -7876,7 +8097,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
selectedTargetId: null,
|
||||
selectedFriendlyActorId: null,
|
||||
effects: [],
|
||||
auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow()),
|
||||
auras,
|
||||
summons: [],
|
||||
areaEffects: [],
|
||||
pendingGroundTarget: null,
|
||||
@@ -7912,13 +8133,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return false;
|
||||
}
|
||||
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.level,
|
||||
talentModifiers,
|
||||
state.gearStats,
|
||||
state.raceId,
|
||||
state.secondaryClassId,
|
||||
auras,
|
||||
);
|
||||
const maxResource = talentAdjustedMaxResource(
|
||||
state.classId,
|
||||
@@ -7935,7 +8158,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
||||
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."),
|
||||
});
|
||||
return true;
|
||||
@@ -7944,13 +8167,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
resetTalents: () => set((state) => {
|
||||
const talentRanks = resetTalentRanks(state.talentRanks, state.classId);
|
||||
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.level,
|
||||
talentModifiers,
|
||||
state.gearStats,
|
||||
state.raceId,
|
||||
state.secondaryClassId,
|
||||
auras,
|
||||
);
|
||||
const maxResource = talentAdjustedMaxResource(
|
||||
state.classId,
|
||||
@@ -7967,7 +8192,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
||||
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."),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCombatStore } from "./combatStore";
|
||||
import { respawnDefeatedPlayer } from "./deathRespawn";
|
||||
import { useGameStore } from "./store";
|
||||
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
||||
import { MANA_POOL_MULTIPLIER } from "./wotlkStats";
|
||||
|
||||
describe("death respawn", () => {
|
||||
beforeEach(() => {
|
||||
@@ -24,7 +25,9 @@ describe("death respawn", () => {
|
||||
useCombatStore.getState().selectMob("respawn-pack:a");
|
||||
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().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;
|
||||
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
|
||||
|
||||
@@ -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", () => {
|
||||
const packagesByMap = new Map(
|
||||
manastormPackages.packages.map((assetPackage) => [assetPackage.mapId, assetPackage]),
|
||||
|
||||
+42
-16
@@ -25,30 +25,56 @@ function normalizedName(value: string): string {
|
||||
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(
|
||||
authored: DungeonDefinition,
|
||||
generated: DungeonDefinition | undefined,
|
||||
): DungeonDefinition {
|
||||
if (!generated) return authored;
|
||||
const generatedByName = new Map(
|
||||
Object.values(generated.entities).map((entity) => [normalizedName(entity.name), entity]),
|
||||
const authoredByName = new Map(
|
||||
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 {
|
||||
...authored,
|
||||
entities: Object.fromEntries(
|
||||
Object.entries(authored.entities).map(([id, entity]) => {
|
||||
const generatedEntity = generatedByName.get(normalizedName(entity.name));
|
||||
return [
|
||||
id,
|
||||
entity.combat
|
||||
? entity
|
||||
: generatedEntity?.combat
|
||||
? { ...entity, combat: generatedEntity.combat }
|
||||
: entity,
|
||||
];
|
||||
}),
|
||||
),
|
||||
entities: {
|
||||
...generatedEntities,
|
||||
...authored.entities,
|
||||
},
|
||||
staticSpawns: [
|
||||
...generated.staticSpawns.filter((spawn) => !generatedBossEntityIds.has(spawn.entityId)),
|
||||
...authored.staticSpawns,
|
||||
],
|
||||
roamingPacks: [
|
||||
...generated.roamingPacks,
|
||||
...authored.roamingPacks.filter((pack) => (
|
||||
pack.members.some((member) => authoredBossEntityIds.has(member.entityId))
|
||||
)),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
export default "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
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
|
||||
@@ -72,6 +72,8 @@ export interface MobPackDefinition {
|
||||
readonly formationSource: "database-solo" | "inferred-followers";
|
||||
/** Trinity-style difficulty mask. Omitted legacy fixtures default to normal (1). */
|
||||
readonly spawnMask?: number;
|
||||
/** Per-instance survival chance after source initialization scripts run. */
|
||||
readonly spawnChance?: number;
|
||||
readonly waypoints: readonly MobVector3[];
|
||||
readonly members: readonly MobPackMemberDefinition[];
|
||||
}
|
||||
@@ -83,6 +85,8 @@ export interface StaticMobSpawnDefinition {
|
||||
readonly yaw?: number;
|
||||
/** Trinity-style difficulty mask. Omitted legacy fixtures default to normal (1). */
|
||||
readonly spawnMask?: number;
|
||||
/** Per-instance survival chance after source initialization scripts run. */
|
||||
readonly spawnChance?: number;
|
||||
}
|
||||
|
||||
export interface StaticMobSpawnCluster {
|
||||
@@ -183,6 +187,7 @@ export function normalizeMobPopulationPaths(
|
||||
anchor[2] - member.formationOffset[2],
|
||||
],
|
||||
spawnMask: pack.spawnMask,
|
||||
spawnChance: pack.spawnChance,
|
||||
}));
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -121,7 +121,8 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
|
||||
}] : [];
|
||||
}
|
||||
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;
|
||||
const phase = useWailingEncounterStore.getState().phase;
|
||||
return phase === "mutanus" || phase === "complete"
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
|
||||
import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { deriveCharacterStats } from "./wotlkStats";
|
||||
import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
|
||||
|
||||
const RACE_MATRIX = {
|
||||
"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", () => {
|
||||
expect(deriveCharacterStats("rom-priest", 1, EMPTY_ITEM_STATS, "rom-human").mana).toBe(103);
|
||||
expect(deriveCharacterStats("rom-druid", 1, EMPTY_ITEM_STATS, "rom-elf").mana).toBe(86);
|
||||
expect(deriveCharacterStats("rom-priest", 1, EMPTY_ITEM_STATS, "rom-human").mana).toBe(
|
||||
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([
|
||||
{ resource: "mana", amount: 12, percentage: false },
|
||||
]);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
deriveCharacterStats,
|
||||
hastedDuration,
|
||||
healthFromTotalStamina,
|
||||
MANA_POOL_MULTIPLIER,
|
||||
ratingMultiplier,
|
||||
ratingPerPercent,
|
||||
} from "./wotlkStats";
|
||||
@@ -50,7 +51,7 @@ describe("WoW 3.3.5a character stats", () => {
|
||||
spellPower: 7,
|
||||
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.spellCritPercent).toBeGreaterThan(mageBase.spellCritPercent);
|
||||
expect(mageGeared.manaPer5).toBeGreaterThan(mageBase.manaPer5 + 4);
|
||||
|
||||
@@ -105,6 +105,8 @@ interface CombatRatingsData {
|
||||
|
||||
const BASE_STATS = baseStatsData as BaseStatsData;
|
||||
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(
|
||||
CLASSES.map((entry) => [entry.id, classById(baseClassFor(entry.id)).dbcId]),
|
||||
) as Record<ClassId, number>;
|
||||
@@ -279,7 +281,7 @@ export function deriveCharacterStats(
|
||||
modifiers.maxHealth,
|
||||
);
|
||||
const mana = classUsesMana(classId)
|
||||
? percentAdjusted(
|
||||
? MANA_POOL_MULTIPLIER * percentAdjusted(
|
||||
resources.mana
|
||||
+ (romBase ? intellect + spirit * 5 : manaFromTotalIntellect(intellect))
|
||||
+ gear.mana,
|
||||
|
||||
+11
-2
@@ -51,6 +51,7 @@ import { useCombatStore } from "../game/combatStore";
|
||||
import { numericFrameRateLimit } from "../game/frameRateSettings";
|
||||
import { useDocumentVisibility } from "../platform/useDocumentVisibility";
|
||||
import { CappedFrameLoop } from "./CappedFrameLoop";
|
||||
import { selectDungeonSessionPopulation } from "../game/dungeonSpawnSelection";
|
||||
|
||||
export function GameScene() {
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
@@ -86,16 +87,24 @@ export function GameScene() {
|
||||
: { entities: dungeon.entities, staticSpawns: dungeon.staticSpawns },
|
||||
[dungeon, gameMode, manastormEncounter],
|
||||
);
|
||||
const roamingPacks = manastormStageRoamingPacks(
|
||||
const difficultyRoamingPacks = manastormStageRoamingPacks(
|
||||
dungeon.roamingPacks.filter((pack) => spawnMatchesDifficulty(pack.spawnMask, difficultyMask)),
|
||||
gameMode === "manastorm" ? manastormEncounter : null,
|
||||
);
|
||||
const difficultySpawns = stagePopulation.staticSpawns.filter(
|
||||
(spawn) => spawnMatchesDifficulty(spawn.spawnMask, difficultyMask),
|
||||
);
|
||||
const staticSpawns = activeDungeonId === "wailing-caverns" && gameMode === "dungeon"
|
||||
const phasedStaticSpawns = activeDungeonId === "wailing-caverns" && gameMode === "dungeon"
|
||||
? wailingStaticSpawnsForPhase(difficultySpawns, wailingPhase)
|
||||
: difficultySpawns;
|
||||
const sessionPopulation = selectDungeonSessionPopulation(
|
||||
activeDungeonId,
|
||||
sessionRevision,
|
||||
phasedStaticSpawns,
|
||||
difficultyRoamingPacks,
|
||||
);
|
||||
const staticSpawns = sessionPopulation.staticSpawns;
|
||||
const roamingPacks = sessionPopulation.roamingPacks;
|
||||
const paused = useGameStore((state) => state.paused);
|
||||
const mapOpen = useGameStore((state) => state.mapOpen);
|
||||
const companionOpen = useGameStore((state) => state.companionOpen);
|
||||
|
||||
@@ -1426,7 +1426,7 @@ export function PartyPopulation({
|
||||
&& wailingPhase !== "mutanus"
|
||||
&& wailingPhase !== "complete"
|
||||
? dungeon.objectiveOrder.filter((objective) => !objective.eventOnly)
|
||||
: dungeon.objectiveOrder
|
||||
: dungeon.objectiveOrder.filter((objective) => !objective.optional)
|
||||
: [];
|
||||
const assets = useMemo(
|
||||
() => manastormPackage
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
auraDefinitionIcon,
|
||||
auraDefinitionIsLongTerm,
|
||||
auraDescription,
|
||||
} from "./AuraStrip";
|
||||
groupAurasForDisplay,
|
||||
} from "./auraDisplay";
|
||||
|
||||
function aura(overrides: Partial<AuraDefinition> = {}): AuraDefinition {
|
||||
return {
|
||||
@@ -71,4 +72,25 @@ describe("aura strip visibility", () => {
|
||||
absorbs: [{ id: "test-absorb", amount: 250 }],
|
||||
}), 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
@@ -1,11 +1,17 @@
|
||||
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 { formatTimedEffectTimer } from "../game/timedEffectDisplay";
|
||||
import {
|
||||
auraDefinitionDisplaysInStrip,
|
||||
auraDefinitionIcon,
|
||||
auraDefinitionIsLongTerm,
|
||||
auraDescription,
|
||||
auraDisplayName,
|
||||
groupAurasForDisplay,
|
||||
} from "./auraDisplay";
|
||||
import type { StatusEffectInspectHandler, StatusEffectInspection } from "./statusEffectInspection";
|
||||
|
||||
export const LONG_TERM_AURA_DURATION_MS = 60_000;
|
||||
|
||||
interface AuraStripProps {
|
||||
readonly targetId: string;
|
||||
readonly targetIds?: readonly string[];
|
||||
@@ -18,43 +24,6 @@ interface AuraStripProps {
|
||||
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({
|
||||
targetId,
|
||||
targetIds,
|
||||
@@ -69,14 +38,14 @@ export function AuraStrip({
|
||||
const allAuras = useCombatStore((state) => state.auras);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const requestedTargetIds = targetIds ?? [targetId];
|
||||
const auras = allAuras.filter((aura) => (
|
||||
const auras = groupAurasForDisplay(allAuras.filter((aura) => (
|
||||
requestedTargetIds.includes(aura.targetId)
|
||||
&& dispositions.includes(aura.definition.disposition)
|
||||
&& auraDefinitionDisplaysInStrip(aura.definition)
|
||||
&& (!hideLongTerm || !auraDefinitionIsLongTerm(aura.definition))
|
||||
&& (!showOnlyLongTerm || auraDefinitionIsLongTerm(aura.definition))
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||
));
|
||||
)));
|
||||
|
||||
useEffect(() => {
|
||||
if (!auras.some((aura) => aura.expiresAt !== null)) return undefined;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||
import type { ActiveAura, AuraDefinition } from "../game/combatAuras";
|
||||
import { auraDisplaysInPlayerStatusPanel } from "./PlayerStatusPanel";
|
||||
import { auraDisplaysInPlayerStatusPanel } from "./auraDisplay";
|
||||
|
||||
function activeAura(
|
||||
targetId: string,
|
||||
@@ -31,18 +31,16 @@ function activeAura(
|
||||
|
||||
describe("top-right status visibility", () => {
|
||||
const now = 20_000;
|
||||
const partyTargets = new Set(["party-1"]);
|
||||
|
||||
it("shows every player aura and centralizes long-term party auras", () => {
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 15_000), now, partyTargets)).toBe(true);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", 60_000), now, partyTargets)).toBe(true);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", null), now, partyTargets)).toBe(true);
|
||||
it("shows every active player aura without including party-member copies", () => {
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 15_000), now)).toBe(true);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", 60_000), now)).toBe(false);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", null), now)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves short party effects on party frames and rejects unrelated, hidden, or expired auras", () => {
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", 30_000), now, partyTargets)).toBe(false);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("stranger", 60_000), now, partyTargets)).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, partyTargets)).toBe(false);
|
||||
it("rejects unrelated, hidden, or expired auras", () => {
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura("stranger", 60_000), now)).toBe(false);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 60_000, 70_000, true), now)).toBe(false);
|
||||
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 5_000, 15_000), now)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { abilityById } from "../game/abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||
import type { ActiveAura } from "../game/combatAuras";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { usePartyStore } from "../game/partyStore";
|
||||
import { formatTimedEffectTimer, timedEffectRemainingMs } from "../game/timedEffectDisplay";
|
||||
import { AuraStrip } from "./AuraStrip";
|
||||
import {
|
||||
AuraStrip,
|
||||
auraDefinitionDisplaysInStrip,
|
||||
auraDefinitionIsLongTerm,
|
||||
auraDisplaysInPlayerStatusPanel,
|
||||
auraDescription,
|
||||
auraDisplayName,
|
||||
} from "./AuraStrip";
|
||||
groupAurasForDisplay,
|
||||
} from "./auraDisplay";
|
||||
import { TimedEffectStrip } from "./TimedEffectStrip";
|
||||
import {
|
||||
timedEffectDescription,
|
||||
@@ -25,34 +23,16 @@ function statusLabel(disposition: StatusEffectInspection["disposition"]): string
|
||||
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 }) {
|
||||
const auras = useCombatStore((state) => state.auras);
|
||||
const effects = useCombatStore((state) => state.effects);
|
||||
const shield = useCombatStore((state) => state.shield);
|
||||
const shieldExpiresAt = useCombatStore((state) => state.shieldExpiresAt);
|
||||
const partyMembers = usePartyStore((state) => state.members);
|
||||
const [inspected, setInspected] = useState<StatusEffectInspection | null>(null);
|
||||
|
||||
const partyAuraTargetIds = partyMembers.map((member) => member.id);
|
||||
const partyAuraTargetIdSet = new Set(partyAuraTargetIds);
|
||||
const partyAuraTargetLabels = Object.fromEntries(
|
||||
partyMembers.map((member) => [member.id, member.name]),
|
||||
);
|
||||
const activeAuras = auras.filter((aura) => (
|
||||
auraDisplaysInPlayerStatusPanel(aura, now, partyAuraTargetIdSet)
|
||||
));
|
||||
const activeAuras = groupAurasForDisplay(auras.filter((aura) => (
|
||||
auraDisplaysInPlayerStatusPanel(aura, now)
|
||||
)));
|
||||
const activeEffects = effects.filter((effect) => (
|
||||
PLAYER_EFFECT_TARGETS.includes(effect.targetId)
|
||||
&& timedEffectRemainingMs(effect, now) > 0
|
||||
@@ -152,18 +132,6 @@ export function PlayerStatusPanel({ now }: { readonly now: number }) {
|
||||
onInspect={setInspected}
|
||||
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
|
||||
targetId={null}
|
||||
targetIds={PLAYER_EFFECT_TARGETS}
|
||||
@@ -189,18 +157,6 @@ export function PlayerStatusPanel({ now }: { readonly now: number }) {
|
||||
onInspect={setInspected}
|
||||
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
|
||||
targetId={null}
|
||||
targetIds={PLAYER_EFFECT_TARGETS}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user