feat(ass): Milestone M26 Assassin Asset Pipeline and Trap Mechanics (closes #473)

This commit is contained in:
troytt 2026-09-25 03:30:28 +00:00
parent 2e65415aed
commit 880b196c43
40 changed files with 7098 additions and 0 deletions

View File

@ -0,0 +1,69 @@
{
"name": "bladefury",
"celFile": "Expansion/bladefury",
"width": 512,
"height": 6,
"directions": 1,
"framesPerDirection": 7,
"animSpeed": 16,
"groups": [
[
[
0,
0,
13,
6,
-6,
-34
],
[
14,
0,
13,
6,
-6,
-34
],
[
28,
0,
13,
6,
-6,
-34
],
[
42,
0,
13,
6,
-6,
-34
],
[
56,
0,
13,
6,
-6,
-34
],
[
70,
0,
13,
6,
-6,
-34
],
[
84,
0,
13,
6,
-6,
-34
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 B

View File

@ -0,0 +1,69 @@
{
"name": "bladefuryb",
"celFile": "Expansion/bladefury_B",
"width": 512,
"height": 9,
"directions": 1,
"framesPerDirection": 7,
"animSpeed": 16,
"groups": [
[
[
0,
0,
20,
9,
-10,
-35
],
[
21,
0,
20,
9,
-10,
-35
],
[
42,
0,
20,
9,
-10,
-35
],
[
63,
0,
20,
9,
-10,
-35
],
[
84,
0,
20,
9,
-10,
-35
],
[
105,
0,
20,
9,
-10,
-35
],
[
126,
0,
20,
9,
-10,
-35
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,69 @@
{
"name": "bladefuryc",
"celFile": "Expansion/bladefury_C",
"width": 512,
"height": 11,
"directions": 1,
"framesPerDirection": 7,
"animSpeed": 16,
"groups": [
[
[
0,
0,
24,
11,
-12,
-36
],
[
25,
0,
24,
11,
-12,
-36
],
[
50,
0,
24,
11,
-12,
-36
],
[
75,
0,
24,
11,
-12,
-36
],
[
100,
0,
24,
11,
-12,
-36
],
[
125,
0,
24,
11,
-12,
-36
],
[
150,
0,
24,
11,
-12,
-36
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,53 @@
{
"name": "bladeshieldmissile",
"celFile": "Expansion/blade_shield_missile",
"width": 512,
"height": 11,
"directions": 1,
"framesPerDirection": 5,
"animSpeed": 16,
"groups": [
[
[
0,
0,
20,
11,
-10,
-7
],
[
21,
0,
20,
11,
-10,
-7
],
[
42,
0,
20,
11,
-10,
-7
],
[
63,
0,
20,
11,
-10,
-7
],
[
84,
0,
20,
11,
-10,
-7
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 B

View File

@ -0,0 +1,109 @@
{
"name": "deathsentryexplode",
"celFile": "Expansion/dethsentry02",
"width": 512,
"height": 275,
"directions": 1,
"framesPerDirection": 12,
"animSpeed": 16,
"groups": [
[
[
0,
0,
115,
91,
-66,
-66
],
[
116,
0,
115,
91,
-66,
-66
],
[
232,
0,
115,
91,
-66,
-66
],
[
348,
0,
115,
91,
-66,
-66
],
[
0,
92,
115,
91,
-66,
-66
],
[
116,
92,
115,
91,
-66,
-66
],
[
232,
92,
115,
91,
-66,
-66
],
[
348,
92,
115,
91,
-66,
-66
],
[
0,
184,
115,
91,
-66,
-66
],
[
116,
184,
115,
91,
-66,
-66
],
[
232,
184,
115,
91,
-66,
-66
],
[
348,
184,
115,
91,
-66,
-66
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@ -0,0 +1,173 @@
{
"name": "firetrauma",
"celFile": "expansion/FireTraumaMissile",
"width": 512,
"height": 10,
"directions": 1,
"framesPerDirection": 20,
"animSpeed": 16,
"groups": [
[
[
0,
0,
12,
10,
-6,
-13
],
[
13,
0,
12,
10,
-6,
-13
],
[
26,
0,
12,
10,
-6,
-13
],
[
39,
0,
12,
10,
-6,
-13
],
[
52,
0,
12,
10,
-6,
-13
],
[
65,
0,
12,
10,
-6,
-13
],
[
78,
0,
12,
10,
-6,
-13
],
[
91,
0,
12,
10,
-6,
-13
],
[
104,
0,
12,
10,
-6,
-13
],
[
117,
0,
12,
10,
-6,
-13
],
[
130,
0,
12,
10,
-6,
-13
],
[
143,
0,
12,
10,
-6,
-13
],
[
156,
0,
12,
10,
-6,
-13
],
[
169,
0,
12,
10,
-6,
-13
],
[
182,
0,
12,
10,
-6,
-13
],
[
195,
0,
12,
10,
-6,
-13
],
[
208,
0,
12,
10,
-6,
-13
],
[
221,
0,
12,
10,
-6,
-13
],
[
234,
0,
12,
10,
-6,
-13
],
[
247,
0,
12,
10,
-6,
-13
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,173 @@
{
"name": "firetraumaexplode",
"celFile": "expansion/FireTraumaXplosion",
"width": 512,
"height": 451,
"directions": 1,
"framesPerDirection": 20,
"animSpeed": 16,
"groups": [
[
[
0,
0,
87,
112,
-45,
-111
],
[
88,
0,
87,
112,
-45,
-111
],
[
176,
0,
87,
112,
-45,
-111
],
[
264,
0,
87,
112,
-45,
-111
],
[
352,
0,
87,
112,
-45,
-111
],
[
0,
113,
87,
112,
-45,
-111
],
[
88,
113,
87,
112,
-45,
-111
],
[
176,
113,
87,
112,
-45,
-111
],
[
264,
113,
87,
112,
-45,
-111
],
[
352,
113,
87,
112,
-45,
-111
],
[
0,
226,
87,
112,
-45,
-111
],
[
88,
226,
87,
112,
-45,
-111
],
[
176,
226,
87,
112,
-45,
-111
],
[
264,
226,
87,
112,
-45,
-111
],
[
352,
226,
87,
112,
-45,
-111
],
[
0,
339,
87,
112,
-45,
-111
],
[
88,
339,
87,
112,
-45,
-111
],
[
176,
339,
87,
112,
-45,
-111
],
[
264,
339,
87,
112,
-45,
-111
],
[
352,
339,
87,
112,
-45,
-111
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -0,0 +1,21 @@
{
"name": "firetraumaground",
"celFile": "expansion/FireTraumaGround",
"width": 512,
"height": 10,
"directions": 1,
"framesPerDirection": 1,
"animSpeed": 16,
"groups": [
[
[
0,
0,
11,
10,
-5,
-13
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,667 @@
{
"name": "shockfieldground",
"celFile": "Expansion/ShockField",
"width": 1024,
"height": 647,
"directions": 8,
"framesPerDirection": 10,
"animSpeed": 16,
"groups": [
[
[
0,
0,
102,
60,
-56,
-32
],
[
103,
0,
102,
60,
-56,
-32
],
[
206,
0,
102,
60,
-56,
-32
],
[
309,
0,
102,
60,
-56,
-32
],
[
412,
0,
102,
60,
-56,
-32
],
[
515,
0,
102,
60,
-56,
-32
],
[
618,
0,
102,
60,
-56,
-32
],
[
721,
0,
102,
60,
-56,
-32
],
[
824,
0,
102,
60,
-56,
-32
],
[
0,
61,
102,
60,
-56,
-32
]
],
[
[
103,
61,
95,
60,
-40,
-31
],
[
199,
61,
95,
60,
-40,
-31
],
[
295,
61,
95,
60,
-40,
-31
],
[
391,
61,
95,
60,
-40,
-31
],
[
487,
61,
95,
60,
-40,
-31
],
[
583,
61,
95,
60,
-40,
-31
],
[
679,
61,
95,
60,
-40,
-31
],
[
775,
61,
95,
60,
-40,
-31
],
[
871,
61,
95,
60,
-40,
-31
],
[
0,
122,
95,
60,
-40,
-31
]
],
[
[
96,
122,
100,
77,
-43,
-31
],
[
197,
122,
100,
77,
-43,
-31
],
[
298,
122,
100,
77,
-43,
-31
],
[
399,
122,
100,
77,
-43,
-31
],
[
500,
122,
100,
77,
-43,
-31
],
[
601,
122,
100,
77,
-43,
-31
],
[
702,
122,
100,
77,
-43,
-31
],
[
803,
122,
100,
77,
-43,
-31
],
[
904,
122,
100,
77,
-43,
-31
],
[
0,
200,
100,
77,
-43,
-31
]
],
[
[
101,
200,
96,
78,
-49,
-33
],
[
198,
200,
96,
78,
-49,
-33
],
[
295,
200,
96,
78,
-49,
-33
],
[
392,
200,
96,
78,
-49,
-33
],
[
489,
200,
96,
78,
-49,
-33
],
[
586,
200,
96,
78,
-49,
-33
],
[
683,
200,
96,
78,
-49,
-33
],
[
780,
200,
96,
78,
-49,
-33
],
[
877,
200,
96,
78,
-49,
-33
],
[
0,
279,
96,
78,
-49,
-33
]
],
[
[
97,
279,
95,
77,
-41,
-34
],
[
193,
279,
95,
77,
-41,
-34
],
[
289,
279,
95,
77,
-41,
-34
],
[
385,
279,
95,
77,
-41,
-34
],
[
481,
279,
95,
77,
-41,
-34
],
[
577,
279,
95,
77,
-41,
-34
],
[
673,
279,
95,
77,
-41,
-34
],
[
769,
279,
95,
77,
-41,
-34
],
[
865,
279,
95,
77,
-41,
-34
],
[
0,
358,
95,
77,
-41,
-34
]
],
[
[
96,
358,
105,
56,
-50,
-27
],
[
202,
358,
105,
56,
-50,
-27
],
[
308,
358,
105,
56,
-50,
-27
],
[
414,
358,
105,
56,
-50,
-27
],
[
520,
358,
105,
56,
-50,
-27
],
[
626,
358,
105,
56,
-50,
-27
],
[
732,
358,
105,
56,
-50,
-27
],
[
838,
358,
105,
56,
-50,
-27
],
[
0,
436,
105,
56,
-50,
-27
],
[
106,
436,
105,
56,
-50,
-27
]
],
[
[
212,
436,
93,
70,
-53,
-33
],
[
306,
436,
93,
70,
-53,
-33
],
[
400,
436,
93,
70,
-53,
-33
],
[
494,
436,
93,
70,
-53,
-33
],
[
588,
436,
93,
70,
-53,
-33
],
[
682,
436,
93,
70,
-53,
-33
],
[
776,
436,
93,
70,
-53,
-33
],
[
870,
436,
93,
70,
-53,
-33
],
[
0,
507,
93,
70,
-53,
-33
],
[
94,
507,
93,
70,
-53,
-33
]
],
[
[
188,
507,
101,
69,
-55,
-32
],
[
290,
507,
101,
69,
-55,
-32
],
[
392,
507,
101,
69,
-55,
-32
],
[
494,
507,
101,
69,
-55,
-32
],
[
596,
507,
101,
69,
-55,
-32
],
[
698,
507,
101,
69,
-55,
-32
],
[
800,
507,
101,
69,
-55,
-32
],
[
902,
507,
101,
69,
-55,
-32
],
[
0,
578,
101,
69,
-55,
-32
],
[
102,
578,
101,
69,
-55,
-32
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

View File

@ -0,0 +1,188 @@
{
"name": "blade_shield",
"celFile": "Expansion/blade_shield",
"width": 512,
"height": 200,
"directions": 1,
"frames": 20,
"framesPerDirection": 20,
"animRate": 16,
"trans": 3,
"preDraw": false,
"lightRadius": 0,
"lightColor": [
255,
255,
255
],
"box": {
"left": -33,
"top": -68,
"width": 64,
"height": 66
},
"groups": [
[
[
0,
0,
64,
66,
-33,
-68
],
[
65,
0,
64,
66,
-33,
-68
],
[
130,
0,
64,
66,
-33,
-68
],
[
195,
0,
64,
66,
-33,
-68
],
[
260,
0,
64,
66,
-33,
-68
],
[
325,
0,
64,
66,
-33,
-68
],
[
390,
0,
64,
66,
-33,
-68
],
[
0,
67,
64,
66,
-33,
-68
],
[
65,
67,
64,
66,
-33,
-68
],
[
130,
67,
64,
66,
-33,
-68
],
[
195,
67,
64,
66,
-33,
-68
],
[
260,
67,
64,
66,
-33,
-68
],
[
325,
67,
64,
66,
-33,
-68
],
[
390,
67,
64,
66,
-33,
-68
],
[
0,
134,
64,
66,
-33,
-68
],
[
65,
134,
64,
66,
-33,
-68
],
[
130,
134,
64,
66,
-33,
-68
],
[
195,
134,
64,
66,
-33,
-68
],
[
260,
134,
64,
66,
-33,
-68
],
[
325,
134,
64,
66,
-33,
-68
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1,188 @@
{
"name": "blade_shield",
"celFile": "Expansion/blade_shield",
"width": 512,
"height": 200,
"directions": 1,
"frames": 20,
"framesPerDirection": 20,
"animRate": 16,
"trans": 3,
"preDraw": false,
"lightRadius": 0,
"lightColor": [
255,
255,
255
],
"box": {
"left": -33,
"top": -68,
"width": 64,
"height": 66
},
"groups": [
[
[
0,
0,
64,
66,
-33,
-68
],
[
65,
0,
64,
66,
-33,
-68
],
[
130,
0,
64,
66,
-33,
-68
],
[
195,
0,
64,
66,
-33,
-68
],
[
260,
0,
64,
66,
-33,
-68
],
[
325,
0,
64,
66,
-33,
-68
],
[
390,
0,
64,
66,
-33,
-68
],
[
0,
67,
64,
66,
-33,
-68
],
[
65,
67,
64,
66,
-33,
-68
],
[
130,
67,
64,
66,
-33,
-68
],
[
195,
67,
64,
66,
-33,
-68
],
[
260,
67,
64,
66,
-33,
-68
],
[
325,
67,
64,
66,
-33,
-68
],
[
390,
67,
64,
66,
-33,
-68
],
[
0,
134,
64,
66,
-33,
-68
],
[
65,
134,
64,
66,
-33,
-68
],
[
130,
134,
64,
66,
-33,
-68
],
[
195,
134,
64,
66,
-33,
-68
],
[
260,
134,
64,
66,
-33,
-68
],
[
325,
134,
64,
66,
-33,
-68
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1,188 @@
{
"name": "blade_shield",
"celFile": "Expansion/blade_shield",
"width": 512,
"height": 200,
"directions": 1,
"frames": 20,
"framesPerDirection": 20,
"animRate": 16,
"trans": 3,
"preDraw": false,
"lightRadius": 0,
"lightColor": [
255,
255,
255
],
"box": {
"left": -33,
"top": -68,
"width": 64,
"height": 66
},
"groups": [
[
[
0,
0,
64,
66,
-33,
-68
],
[
65,
0,
64,
66,
-33,
-68
],
[
130,
0,
64,
66,
-33,
-68
],
[
195,
0,
64,
66,
-33,
-68
],
[
260,
0,
64,
66,
-33,
-68
],
[
325,
0,
64,
66,
-33,
-68
],
[
390,
0,
64,
66,
-33,
-68
],
[
0,
67,
64,
66,
-33,
-68
],
[
65,
67,
64,
66,
-33,
-68
],
[
130,
67,
64,
66,
-33,
-68
],
[
195,
67,
64,
66,
-33,
-68
],
[
260,
67,
64,
66,
-33,
-68
],
[
325,
67,
64,
66,
-33,
-68
],
[
390,
67,
64,
66,
-33,
-68
],
[
0,
134,
64,
66,
-33,
-68
],
[
65,
134,
64,
66,
-33,
-68
],
[
130,
134,
64,
66,
-33,
-68
],
[
195,
134,
64,
66,
-33,
-68
],
[
260,
134,
64,
66,
-33,
-68
],
[
325,
134,
64,
66,
-33,
-68
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1,124 @@
{
"name": "deathsentry_explode",
"celFile": "expansion/dethsentry01",
"width": 512,
"height": 547,
"directions": 1,
"frames": 12,
"framesPerDirection": 12,
"animRate": 16,
"trans": 3,
"preDraw": false,
"lightRadius": 0,
"lightColor": [
255,
255,
255
],
"box": {
"left": -73,
"top": -93,
"width": 130,
"height": 136
},
"groups": [
[
[
0,
0,
130,
136,
-73,
-93
],
[
131,
0,
130,
136,
-73,
-93
],
[
262,
0,
130,
136,
-73,
-93
],
[
0,
137,
130,
136,
-73,
-93
],
[
131,
137,
130,
136,
-73,
-93
],
[
262,
137,
130,
136,
-73,
-93
],
[
0,
274,
130,
136,
-73,
-93
],
[
131,
274,
130,
136,
-73,
-93
],
[
262,
274,
130,
136,
-73,
-93
],
[
0,
411,
130,
136,
-73,
-93
],
[
131,
411,
130,
136,
-73,
-93
],
[
262,
411,
130,
136,
-73,
-93
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -0,0 +1,124 @@
{
"name": "deathsentry_explode",
"celFile": "expansion/dethsentry01",
"width": 512,
"height": 547,
"directions": 1,
"frames": 12,
"framesPerDirection": 12,
"animRate": 16,
"trans": 3,
"preDraw": false,
"lightRadius": 0,
"lightColor": [
255,
255,
255
],
"box": {
"left": -73,
"top": -93,
"width": 130,
"height": 136
},
"groups": [
[
[
0,
0,
130,
136,
-73,
-93
],
[
131,
0,
130,
136,
-73,
-93
],
[
262,
0,
130,
136,
-73,
-93
],
[
0,
137,
130,
136,
-73,
-93
],
[
131,
137,
130,
136,
-73,
-93
],
[
262,
137,
130,
136,
-73,
-93
],
[
0,
274,
130,
136,
-73,
-93
],
[
131,
274,
130,
136,
-73,
-93
],
[
262,
274,
130,
136,
-73,
-93
],
[
0,
411,
130,
136,
-73,
-93
],
[
131,
411,
130,
136,
-73,
-93
],
[
262,
411,
130,
136,
-73,
-93
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -0,0 +1,124 @@
{
"name": "deathsentry_explode",
"celFile": "expansion/dethsentry01",
"width": 512,
"height": 547,
"directions": 1,
"frames": 12,
"framesPerDirection": 12,
"animRate": 16,
"trans": 3,
"preDraw": false,
"lightRadius": 0,
"lightColor": [
255,
255,
255
],
"box": {
"left": -73,
"top": -93,
"width": 130,
"height": 136
},
"groups": [
[
[
0,
0,
130,
136,
-73,
-93
],
[
131,
0,
130,
136,
-73,
-93
],
[
262,
0,
130,
136,
-73,
-93
],
[
0,
137,
130,
136,
-73,
-93
],
[
131,
137,
130,
136,
-73,
-93
],
[
262,
137,
130,
136,
-73,
-93
],
[
0,
274,
130,
136,
-73,
-93
],
[
131,
274,
130,
136,
-73,
-93
],
[
262,
274,
130,
136,
-73,
-93
],
[
0,
411,
130,
136,
-73,
-93
],
[
131,
411,
130,
136,
-73,
-93
],
[
262,
411,
130,
136,
-73,
-93
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -0,0 +1,124 @@
{
"name": "deathsentry_explode",
"celFile": "expansion/dethsentry01",
"width": 512,
"height": 547,
"directions": 1,
"frames": 12,
"framesPerDirection": 12,
"animRate": 16,
"trans": 3,
"preDraw": false,
"lightRadius": 0,
"lightColor": [
255,
255,
255
],
"box": {
"left": -73,
"top": -93,
"width": 130,
"height": 136
},
"groups": [
[
[
0,
0,
130,
136,
-73,
-93
],
[
131,
0,
130,
136,
-73,
-93
],
[
262,
0,
130,
136,
-73,
-93
],
[
0,
137,
130,
136,
-73,
-93
],
[
131,
137,
130,
136,
-73,
-93
],
[
262,
137,
130,
136,
-73,
-93
],
[
0,
274,
130,
136,
-73,
-93
],
[
131,
274,
130,
136,
-73,
-93
],
[
262,
274,
130,
136,
-73,
-93
],
[
0,
411,
130,
136,
-73,
-93
],
[
131,
411,
130,
136,
-73,
-93
],
[
262,
411,
130,
136,
-73,
-93
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -181,6 +181,18 @@ const MISSILE_TARGETS = [
{ name: 'hurricaneswoosh', celFile: 'expansion/hurricane_swoosh', animSpeed: 8, trans: 1 },
{ name: 'hurricanerock', celFile: 'expansion/hurricane_rocks', animSpeed: 16, trans: 0 },
{ name: 'hurricanetree', celFile: 'expansion/hurricane_tree', animSpeed: 16, trans: 0 },
// Assassin Traps
{ name: 'firetrauma', celFile: 'expansion/FireTraumaMissile', animSpeed: 16, trans: 1 },
{ name: 'firetraumaground', celFile: 'expansion/FireTraumaGround', animSpeed: 16, trans: 1 },
{ name: 'firetraumaexplode', celFile: 'expansion/FireTraumaXplosion', animSpeed: 16, trans: 1 },
{ name: 'shockfieldair', celFile: 'expansion/caltrops', animSpeed: 16, trans: 0 },
{ name: 'shockfieldground', celFile: 'Expansion/ShockField', animSpeed: 16, trans: 1 },
{ name: 'bladefury', celFile: 'Expansion/bladefury', animSpeed: 16, trans: 0 },
{ name: 'bladefuryb', celFile: 'Expansion/bladefury_B', animSpeed: 16, trans: 0 },
{ name: 'bladefuryc', celFile: 'Expansion/bladefury_C', animSpeed: 16, trans: 0 },
{ name: 'deathsentryexplode', celFile: 'Expansion/dethsentry02', animSpeed: 16, trans: 1 },
{ name: 'bladeshieldmissile', celFile: 'Expansion/blade_shield_missile', animSpeed: 16, trans: 1 },
] as const
const PAGE_SIZE = 4096

View File

@ -1479,6 +1479,30 @@ export const OVERLAY_TARGETS: readonly OverlayTarget[] = [
lightRadius: 0,
lightColor: [255, 255, 255] as const,
},
// ---------------------------------------------------------------------------
// Authentic Diablo II v1.13c Assassin Trap Overlays
// ---------------------------------------------------------------------------
{
name: 'blade_shield',
celFile: 'Expansion/blade_shield',
aliases: ['bladeshield', 'Expansion/blade_shield'],
animRate: 16,
trans: 3,
preDraw: false,
lightRadius: 0,
lightColor: [255, 255, 255] as const,
},
{
name: 'deathsentry_explode',
celFile: 'expansion/dethsentry01',
aliases: ['deathsentry', 'dethsentry01', 'expansion/dethsentry01'],
animRate: 16,
trans: 3,
preDraw: false,
lightRadius: 0,
lightColor: [255, 255, 255] as const,
},
] as const
export async function bakeOverlays(

View File

@ -0,0 +1,951 @@
/**
* Diablo II: Lord of Destruction v1.13c — Assassin Traps Module
*
* Dedicated pure calculations, stationary sentry limits, NextDelay tracking,
* Death Sentry dual-mode corpse explosion AI, blade skill weapon scaling (3/8, 3/4, 1/4),
* and adversarial kinematics for the 10 Assassin Trap Tree Skills:
* - Skill 251: Fire Trauma / Fire Blast (ReqLevel 1)
* - Skill 256: Shock Field / Shock Web (ReqLevel 6)
* - Skill 257: Blade Sentinel (ReqLevel 6)
* - Skill 261: Charged Bolt Sentry (ReqLevel 12)
* - Skill 262: Wake of Fire Sentry (ReqLevel 12)
* - Skill 266: Blade Fury (ReqLevel 18)
* - Skill 271: Lightning Sentry (ReqLevel 24)
* - Skill 272: Inferno Sentry / Wake of Inferno (ReqLevel 24)
* - Skill 276: Death Sentry (ReqLevel 30)
* - Skill 277: Blade Shield (ReqLevel 30)
*
* 1.13c Ground Truth:
* - Skills.txt, Missiles.txt, MonStats.txt, PetType.txt (Patch_D2.mpq).
* - Global Trap Cap:
* PetType `assassintrap` has global max = 5 across all stationary sentries
* (Charged Bolt, Wake of Fire, Lightning, Inferno, Death Sentry) and Blade Sentinel.
* Summoning a 6th trap causes strict FIFO eviction of the oldest active trap.
* - NextDelay Mechanics:
* Wake of Fire (`wake of destruction maker`): NextDelay = 4 frames (0.16s).
* Shock Web (`shock field on ground`): NextDelay = 25 frames (1.0s).
* Blade Sentinel (`blade creeper`): NextDelay = 25 frames (1.0s).
* Entities taking damage enter a temporary immunity window for missiles sharing that NextDelay.
* - Weapon Damage Scaling (`srcDam` in 128ths):
* Blade Sentinel: srcDam = 48 -> 48 / 128 = 3/8 (37.5%).
* Blade Fury: srcDam = 96 -> 96 / 128 = 3/4 (75.0%).
* Blade Shield: srcDam = 32 -> 32 / 128 = 1/4 (25.0%).
* - Death Sentry Dual-Mode AI:
* Prioritizes valid corpses within radius: 40% to 80% corpse max HP explosion (50% phys / 50% fire).
* Radius: 10 + (slvl - 1) subtiles (3.3 yards base, +0.33 yards/lvl).
* If no corpse in radius, fires piercing lightning bolt at live targets.
* Shots: 5 base + floor(Fire Blast blvl / 3).
* - Zero runtime MPQ reading.
*/
import { compute5BandScaling } from '../engine/calc-ast.ts'
// --- Skill IDs ---
export const ASSASSIN_TRAP_SKILL_IDS = {
FIRE_BLAST: 251,
SHOCK_WEB: 256,
BLADE_SENTINEL: 257,
CHARGED_BOLT_SENTRY: 261,
WAKE_OF_FIRE: 262,
BLADE_FURY: 266,
LIGHTNING_SENTRY: 271,
WAKE_OF_INFERNO: 272,
DEATH_SENTRY: 276,
BLADE_SHIELD: 277,
} as const
// Pet sub-skill IDs for sentries
export const ASSASSIN_PET_SUB_SKILL_IDS = {
WAKE_OF_DESTRUCTION_MAKER: 281,
BOLT_SENTRY: 306,
MON_INFERNO_SENTRY: 311,
MON_DEATH_SENTRY: 312,
SENTRY_LIGHTNING: 313,
DEATH_SENTRY_LTNG: 324,
} as const
// --- Constants ---
export const MAX_ACTIVE_TRAPS = 5
export const TRAP_PET_TYPE = 'assassintrap'
export const NEXT_DELAY_FRAMES = {
WAKE_OF_FIRE: 4,
SHOCK_WEB: 25,
BLADE_SENTINEL: 25,
DRAGON_FLIGHT: 4,
} as const
export const BLADE_SRC_DAM = {
BLADE_SENTINEL: 48, // 48 / 128 = 3/8
BLADE_FURY: 96, // 96 / 128 = 3/4
BLADE_SHIELD: 32, // 32 / 128 = 1/4
} as const
// --- Interfaces ---
export interface ActiveTrap {
readonly id: string
readonly ownerId: string | number
readonly skillId: number
readonly summonMon: string
readonly x: number
readonly y: number
readonly slvl: number
remainingShots: number
readonly createdAtFrame: number
readonly maxLifetimeFrames: number
lastAttackFrame: number
}
export interface NextDelayRecord {
readonly entityId: string | number
readonly channelId: string | number
expiresAtFrame: number
}
// --- NextDelay Engine ---
export class NextDelayTracker {
private readonly records = new Map<string, number>()
private makeKey(entityId: string | number, channel: string | number): string {
return `${entityId}:${channel}`
}
canTakeDamage(entityId: string | number, channel: string | number, currentFrame: number): boolean {
const key = this.makeKey(entityId, channel)
const expiresAt = this.records.get(key)
if (expiresAt === undefined) return true
return currentFrame >= expiresAt
}
recordHit(entityId: string | number, channel: string | number, currentFrame: number, delayFrames: number): void {
const key = this.makeKey(entityId, channel)
this.records.set(key, currentFrame + delayFrames)
}
cleanExpired(currentFrame: number): void {
for (const [key, expiresAt] of this.records.entries()) {
if (currentFrame >= expiresAt) {
this.records.delete(key)
}
}
}
clear(): void {
this.records.clear()
}
}
// --- Trap Manager Engine ---
export class TrapManager {
private readonly traps: ActiveTrap[] = []
private nextTrapId = 1
getActiveTraps(ownerId?: string | number): readonly ActiveTrap[] {
if (ownerId === undefined) return this.traps
return this.traps.filter(t => t.ownerId === ownerId)
}
getActiveTrapCount(ownerId: string | number): number {
return this.traps.filter(t => t.ownerId === ownerId).length
}
registerTrap(trapData: {
ownerId: string | number
skillId: number
summonMon: string
x: number
y: number
slvl: number
shots: number
currentFrame: number
lifetimeFrames?: number
}): { trap: ActiveTrap; evictedTrap?: ActiveTrap | undefined } {
let evictedTrap: ActiveTrap | undefined
const ownerTraps = this.traps.filter(t => t.ownerId === trapData.ownerId)
if (ownerTraps.length >= MAX_ACTIVE_TRAPS) {
// FIFO: evict the oldest trap
evictedTrap = ownerTraps[0]
const idx = this.traps.indexOf(evictedTrap!)
if (idx !== -1) {
this.traps.splice(idx, 1)
}
}
const trap: ActiveTrap = {
id: `trap_${this.nextTrapId++}`,
ownerId: trapData.ownerId,
skillId: trapData.skillId,
summonMon: trapData.summonMon,
x: trapData.x,
y: trapData.y,
slvl: trapData.slvl,
remainingShots: trapData.shots,
createdAtFrame: trapData.currentFrame,
maxLifetimeFrames: trapData.lifetimeFrames ?? 3000,
lastAttackFrame: -1000,
}
this.traps.push(trap)
return { trap, evictedTrap }
}
consumeShot(trapId: string): { remaining: number; destroyed: boolean } {
const idx = this.traps.findIndex(t => t.id === trapId)
if (idx === -1) return { remaining: 0, destroyed: true }
const trap = this.traps[idx]!
trap.remainingShots -= 1
if (trap.remainingShots <= 0) {
this.traps.splice(idx, 1)
return { remaining: 0, destroyed: true }
}
return { remaining: trap.remainingShots, destroyed: false }
}
removeTrap(trapId: string): boolean {
const idx = this.traps.findIndex(t => t.id === trapId)
if (idx === -1) return false
this.traps.splice(idx, 1)
return true
}
clear(ownerId?: string | number): void {
if (ownerId === undefined) {
this.traps.length = 0
} else {
for (let i = this.traps.length - 1; i >= 0; i--) {
if (this.traps[i]!.ownerId === ownerId) {
this.traps.splice(i, 1)
}
}
}
}
}
// --- Pure Damage & Stats Calculators ---
/**
* 1.13c Weapon damage scaling for Assassin Blade skills.
* srcDam is in 128ths.
*/
export function computeBladeWeaponDamage(baseWeaponDamage: number, skillId: number): number {
switch (skillId) {
case ASSASSIN_TRAP_SKILL_IDS.BLADE_SENTINEL:
// 48 / 128 = 3/8 = 37.5%
return Math.floor((baseWeaponDamage * BLADE_SRC_DAM.BLADE_SENTINEL) / 128)
case ASSASSIN_TRAP_SKILL_IDS.BLADE_FURY:
// 96 / 128 = 3/4 = 75.0%
return Math.floor((baseWeaponDamage * BLADE_SRC_DAM.BLADE_FURY) / 128)
case ASSASSIN_TRAP_SKILL_IDS.BLADE_SHIELD:
// 32 / 128 = 1/4 = 25.0%
return Math.floor((baseWeaponDamage * BLADE_SRC_DAM.BLADE_SHIELD) / 128)
default:
return baseWeaponDamage
}
}
/**
* Skill #251: Fire Blast / Fire Trauma
* 1.13c Parity:
* - 5-band Fire damage: base 3-4, +2..+7 per band (HitShift 8)
* - Synergies: +9% per hard point of CBS, WoF, LS, WoI, DS, Shock Web
* - Radius: 5 subtiles base + floor(allTrapSynergies / 3)
*/
export function evaluateFireBlastStats(
slvl: number,
hardPoints: {
shockWeb?: number
chargedBoltSentry?: number
wakeOfFire?: number
lightningSentry?: number
wakeOfInferno?: number
deathSentry?: number
} = {}
): {
minDamage: number
maxDamage: number
minDamage256: number
maxDamage256: number
radiusSubtiles: number
manaCost: number
manaCost256: number
synergyPct: number
} {
const minBase = compute5BandScaling(slvl, 3, 2, 3, 4, 6, 7)
const maxBase = compute5BandScaling(slvl, 4, 3, 4, 6, 8, 9)
const synCount =
(hardPoints.shockWeb ?? 0) +
(hardPoints.chargedBoltSentry ?? 0) +
(hardPoints.wakeOfFire ?? 0) +
(hardPoints.lightningSentry ?? 0) +
(hardPoints.wakeOfInferno ?? 0) +
(hardPoints.deathSentry ?? 0)
const synergyPct = synCount * 9
const mult = 1 + synergyPct / 100
const minDamage = Math.floor(minBase * mult)
const maxDamage = Math.floor(maxBase * mult)
const manaCost = Math.max(1, 3 + Math.floor((slvl - 1) / 8))
const radiusSubtiles = 5 + Math.floor(synCount / 3)
return {
minDamage,
maxDamage,
minDamage256: minDamage * 256,
maxDamage256: maxDamage * 256,
radiusSubtiles,
manaCost,
manaCost256: manaCost * 256,
synergyPct,
}
}
/**
* Skill #256: Shock Web / Shock Field
* 1.13c Parity:
* - Missiles: 6 + floor(slvl / 4) + floor(fireBlastBlvl / 3)
* - 5-band Lightning damage: 2-20 base, max scales +6, +12, +20, +30, +42 (HitShift 7 -> /2)
* - Synergies: +11% per hard point of CBS, LS, DS
* - NextDelay: 25 frames
*/
export function evaluateShockWebStats(
slvl: number,
hardPoints: {
fireBlast?: number
chargedBoltSentry?: number
lightningSentry?: number
deathSentry?: number
} = {}
): {
missileCount: number
minDamage: number
maxDamage: number
minDamage256: number
maxDamage256: number
manaCost: number
synergyPct: number
nextDelayFrames: number
} {
const missileCount = 6 + Math.floor(slvl / 4) + Math.floor((hardPoints.fireBlast ?? 0) / 3)
const minBase = 1 // 2 with hitshift 7 = 1
const maxBase = Math.floor(compute5BandScaling(slvl, 20, 6, 12, 20, 30, 42) / 2)
const synCount =
(hardPoints.chargedBoltSentry ?? 0) +
(hardPoints.lightningSentry ?? 0) +
(hardPoints.deathSentry ?? 0)
const synergyPct = synCount * 11
const mult = 1 + synergyPct / 100
const minDamage = Math.max(1, Math.floor(minBase * mult))
const maxDamage = Math.max(minDamage, Math.floor(maxBase * mult))
return {
missileCount,
minDamage,
maxDamage,
minDamage256: minDamage * 256,
maxDamage256: maxDamage * 256,
manaCost: 6,
synergyPct,
nextDelayFrames: NEXT_DELAY_FRAMES.SHOCK_WEB,
}
}
/**
* Skill #257: Blade Sentinel
* 1.13c Parity:
* - Weapon Damage: 3/8 (srcDam = 48)
* - Flat Physical Damage: 6-10 base, +3/+4/+5 per band
* - Duration: 100 + 12 * (slvl - 1) frames (4s to 13.12s)
* - NextDelay: 25 frames
* - PetType: assassintrap (max 5)
*/
export function evaluateBladeSentinelStats(
slvl: number,
weaponDamage = 0
): {
flatMinPhys: number
flatMaxPhys: number
scaledWeaponPhys: number
totalMinPhys: number
totalMaxPhys: number
durationFrames: number
manaCost: number
attackRatingBonusPct: number
nextDelayFrames: number
petMax: number
} {
const flatMinPhys = compute5BandScaling(slvl, 6, 3, 4, 5, 5, 5)
const flatMaxPhys = compute5BandScaling(slvl, 10, 3, 4, 5, 5, 5)
const scaledWeaponPhys = computeBladeWeaponDamage(weaponDamage, ASSASSIN_TRAP_SKILL_IDS.BLADE_SENTINEL)
const durationFrames = 100 + 12 * (slvl - 1)
const attackRatingBonusPct = slvl * 5
return {
flatMinPhys,
flatMaxPhys,
scaledWeaponPhys,
totalMinPhys: flatMinPhys + scaledWeaponPhys,
totalMaxPhys: flatMaxPhys + scaledWeaponPhys,
durationFrames,
manaCost: 7,
attackRatingBonusPct,
nextDelayFrames: NEXT_DELAY_FRAMES.BLADE_SENTINEL,
petMax: MAX_ACTIVE_TRAPS,
}
}
/**
* Skill #261: Charged Bolt Sentry
* 1.13c Parity:
* - Shots: 5 + floor(LightningSentry.blvl / 4)
* - Bolts per shot: 5 + floor(ShockWeb.blvl / 3)
* - Lightning Damage: 1 base min, max scales +1, +2, +3, +4, +5 (HitShift 8)
* - Synergies: +6% per hard point of Fire Blast, Shock Web, LS, DS
* - PetType: assassintrap (max 5)
*/
export function evaluateChargedBoltSentryStats(
slvl: number,
hardPoints: {
fireBlast?: number
shockWeb?: number
lightningSentry?: number
deathSentry?: number
} = {}
): {
shots: number
boltsPerShot: number
minDamage: number
maxDamage: number
minDamage256: number
maxDamage256: number
manaCost: number
synergyPct: number
petMax: number
} {
const shots = 5 + Math.floor((hardPoints.lightningSentry ?? 0) / 4)
const boltsPerShot = 5 + Math.floor((hardPoints.shockWeb ?? 0) / 3)
const minBase = 1
const maxBase = compute5BandScaling(slvl, 7, 1, 2, 3, 4, 5)
const synCount =
(hardPoints.fireBlast ?? 0) +
(hardPoints.shockWeb ?? 0) +
(hardPoints.lightningSentry ?? 0) +
(hardPoints.deathSentry ?? 0)
const synergyPct = synCount * 6
const mult = 1 + synergyPct / 100
const minDamage = minBase
const maxDamage = Math.max(minDamage, Math.floor(maxBase * mult))
return {
shots,
boltsPerShot,
minDamage,
maxDamage,
minDamage256: minDamage * 256,
maxDamage256: maxDamage * 256,
manaCost: 13,
synergyPct,
petMax: MAX_ACTIVE_TRAPS,
}
}
/**
* Skill #262: Wake of Fire Sentry
* 1.13c Parity:
* - Shots: 5 waves
* - NextDelay: 4 frames
* - Fire Damage: 5-10 base, scales +2, +3, +4, +5, +6 (HitShift 8)
* - Synergies: +8% per hard point of Fire Blast, Wake of Inferno
* - PetType: assassintrap (max 5)
*/
export function evaluateWakeOfFireStats(
slvl: number,
hardPoints: {
fireBlast?: number
wakeOfInferno?: number
} = {}
): {
shots: number
minDamage: number
maxDamage: number
minDamage256: number
maxDamage256: number
manaCost: number
synergyPct: number
nextDelayFrames: number
petMax: number
} {
const minBase = compute5BandScaling(slvl, 5, 2, 3, 4, 5, 6)
const maxBase = compute5BandScaling(slvl, 10, 2, 3, 4, 5, 6)
const synCount = (hardPoints.fireBlast ?? 0) + (hardPoints.wakeOfInferno ?? 0)
const synergyPct = synCount * 8
const mult = 1 + synergyPct / 100
const minDamage = Math.floor(minBase * mult)
const maxDamage = Math.floor(maxBase * mult)
return {
shots: 5,
minDamage,
maxDamage,
minDamage256: minDamage * 256,
maxDamage256: maxDamage * 256,
manaCost: 13,
synergyPct,
nextDelayFrames: NEXT_DELAY_FRAMES.WAKE_OF_FIRE,
petMax: MAX_ACTIVE_TRAPS,
}
}
/**
* Skill #266: Blade Fury
* 1.13c Parity:
* - Weapon Damage: 3/4 (srcDam = 96)
* - Flat Physical Damage: 8-10 base, +3/+5/+8
* - Channeled tick rate: fires every 6 frames (5 frame delay + 1 action)
* - Mana: 3 to start, 1 per shot
*/
export function evaluateBladeFuryStats(
slvl: number,
weaponDamage = 0
): {
flatMinPhys: number
flatMaxPhys: number
scaledWeaponPhys: number
totalMinPhys: number
totalMaxPhys: number
startManaCost: number
perShotManaCost: number
framesPerShot: number
} {
const flatMinPhys = compute5BandScaling(slvl, 8, 3, 5, 8, 8, 8)
const flatMaxPhys = compute5BandScaling(slvl, 10, 3, 5, 8, 8, 8)
const scaledWeaponPhys = computeBladeWeaponDamage(weaponDamage, ASSASSIN_TRAP_SKILL_IDS.BLADE_FURY)
return {
flatMinPhys,
flatMaxPhys,
scaledWeaponPhys,
totalMinPhys: flatMinPhys + scaledWeaponPhys,
totalMaxPhys: flatMaxPhys + scaledWeaponPhys,
startManaCost: 3,
perShotManaCost: 1,
framesPerShot: 6,
}
}
/**
* Skill #271: Lightning Sentry
* 1.13c Parity:
* - Shots: 10 piercing lightning beams
* - Lightning Damage: 1 base min, max scales +7, +10, +13, +16, +19 (HitShift 8)
* - Synergies: +12% per hard point of Shock Web, CBS, DS
* - PetType: assassintrap (max 5)
*/
export function evaluateLightningSentryStats(
slvl: number,
hardPoints: {
shockWeb?: number
chargedBoltSentry?: number
deathSentry?: number
} = {}
): {
shots: number
minDamage: number
maxDamage: number
minDamage256: number
maxDamage256: number
manaCost: number
synergyPct: number
petMax: number
} {
const minBase = 1
const maxBase = compute5BandScaling(slvl, 20, 10, 16, 24, 34, 44)
const synCount =
(hardPoints.shockWeb ?? 0) +
(hardPoints.chargedBoltSentry ?? 0) +
(hardPoints.deathSentry ?? 0)
const synergyPct = synCount * 12
const mult = 1 + synergyPct / 100
const minDamage = minBase
const maxDamage = Math.max(minDamage, Math.floor(maxBase * mult))
return {
shots: 10,
minDamage,
maxDamage,
minDamage256: minDamage * 256,
maxDamage256: maxDamage * 256,
manaCost: 20,
synergyPct,
petMax: MAX_ACTIVE_TRAPS,
}
}
/**
* Skill #272: Inferno Sentry / Wake of Inferno
* 1.13c Parity:
* - Attacks: 10 flamethrower bursts
* - Fire Damage: 1.25 - 3.125 base per tick, scales with level
* - Synergies: +7% per hard point of Fire Blast, Wake of Fire, DS
* - PetType: assassintrap (max 5)
*/
export function evaluateWakeOfInfernoStats(
slvl: number,
hardPoints: {
fireBlast?: number
wakeOfFire?: number
deathSentry?: number
} = {}
): {
attacks: number
minDamage256: number
maxDamage256: number
minDamage: number
maxDamage: number
manaCost: number
synergyPct: number
petMax: number
} {
const minBase256 = compute5BandScaling(slvl, 320, 96, 128, 160, 192, 224)
const maxBase256 = compute5BandScaling(slvl, 800, 144, 192, 240, 288, 336)
const synCount =
(hardPoints.fireBlast ?? 0) +
(hardPoints.wakeOfFire ?? 0) +
(hardPoints.deathSentry ?? 0)
const synergyPct = synCount * 7
const mult = 1 + synergyPct / 100
const minDamage256 = Math.floor(minBase256 * mult)
const maxDamage256 = Math.floor(maxBase256 * mult)
return {
attacks: 10,
minDamage256,
maxDamage256,
minDamage: Math.floor(minDamage256 / 256),
maxDamage: Math.floor(maxDamage256 / 256),
manaCost: 20,
synergyPct,
petMax: MAX_ACTIVE_TRAPS,
}
}
/**
* Skill #276: Death Sentry
* 1.13c Parity:
* - Shots: 5 + floor(FireBlast.blvl / 3)
* - Lightning Damage: 1-50 base, max scales +11, +15, +19, +23, +27 (HitShift 8)
* - Lightning Synergy: +12% per hard point of Lightning Sentry
* - Corpse Explosion:
* 40% to 80% of corpse base HP
* 50% Physical, 50% Fire
* Radius: 10 + (slvl - 1) subtiles (3.3 yards base, +0.33 yards/lvl)
* - PetType: assassintrap (max 5)
*/
export function evaluateDeathSentryStats(
slvl: number,
hardPoints: {
fireBlast?: number
lightningSentry?: number
} = {}
): {
shots: number
minLtngDamage: number
maxLtngDamage: number
minLtngDamage256: number
maxLtngDamage256: number
corpseExplosionMinPct: number
corpseExplosionMaxPct: number
corpseExplosionFireRatio: number
corpseExplosionPhysRatio: number
corpseExplosionRadiusSubtiles: number
manaCost: number
ltngSynergyPct: number
petMax: number
} {
const shots = 5 + Math.floor((hardPoints.fireBlast ?? 0) / 3)
const minBase = 1
const maxBase = compute5BandScaling(slvl, 50, 11, 15, 19, 23, 27)
const ltngSynergyPct = (hardPoints.lightningSentry ?? 0) * 12
const mult = 1 + ltngSynergyPct / 100
const minLtngDamage = minBase
const maxLtngDamage = Math.max(minLtngDamage, Math.floor(maxBase * mult))
const corpseExplosionRadiusSubtiles = 10 + (slvl - 1)
return {
shots,
minLtngDamage,
maxLtngDamage,
minLtngDamage256: minLtngDamage * 256,
maxLtngDamage256: maxLtngDamage * 256,
corpseExplosionMinPct: 40,
corpseExplosionMaxPct: 80,
corpseExplosionFireRatio: 0.5,
corpseExplosionPhysRatio: 0.5,
corpseExplosionRadiusSubtiles,
manaCost: 20,
ltngSynergyPct,
petMax: MAX_ACTIVE_TRAPS,
}
}
/**
* Skill #277: Blade Shield
* 1.13c Parity:
* - Weapon Damage: 1/4 (srcDam = 32)
* - Flat Physical Damage: 1-30 base, +5/+6/+7
* - Duration: 500 + 125 * (slvl - 1) frames (20s to 115s at slvl 20)
* - Periodic Tick: every 25 frames (1.0s)
* - Radius: 6 subtiles (2.0 yards = 64 px)
* - Mana: 27 + 2 * (slvl - 1)
*/
export function evaluateBladeShieldStats(
slvl: number,
weaponDamage = 0
): {
flatMinPhys: number
flatMaxPhys: number
scaledWeaponPhys: number
totalMinPhys: number
totalMaxPhys: number
durationFrames: number
tickRateFrames: number
radiusSubtiles: number
manaCost: number
} {
const flatMinPhys = compute5BandScaling(slvl, 1, 5, 6, 7, 7, 7)
const flatMaxPhys = compute5BandScaling(slvl, 30, 5, 6, 7, 7, 7)
const scaledWeaponPhys = computeBladeWeaponDamage(weaponDamage, ASSASSIN_TRAP_SKILL_IDS.BLADE_SHIELD)
const durationFrames = 500 + 125 * (slvl - 1)
const manaCost = 27 + 2 * (slvl - 1)
return {
flatMinPhys,
flatMaxPhys,
scaledWeaponPhys,
totalMinPhys: flatMinPhys + scaledWeaponPhys,
totalMaxPhys: flatMaxPhys + scaledWeaponPhys,
durationFrames,
tickRateFrames: 25,
radiusSubtiles: 6,
manaCost,
}
}
// --- Kinematics & Simulation Helpers ---
export interface SimulationCorpse {
readonly id: string
readonly x: number
readonly y: number
readonly baseMaxHp: number
consumed: boolean
}
export interface SimulationEnemy {
readonly id: string
readonly x: number
readonly y: number
currentHp: number
readonly maxHp: number
}
/**
* Simulate Death Sentry AI target selection:
* 1. If valid unconsumed corpse within explosion radius -> triggers Corpse Explosion (50% fire, 50% phys).
* 2. Else if enemy within lightning attack range (e.g. 15 yards / 480px) -> triggers Lightning Bolt.
* 3. Else -> idle.
*/
export function simulateDeathSentryAI(
trap: ActiveTrap,
corpses: SimulationCorpse[],
enemies: SimulationEnemy[],
options: {
fireBlastBlvl?: number
lightningSentryBlvl?: number
subtilePixelSize?: number
} = {}
): {
action: 'corpse_explosion' | 'lightning' | 'idle'
targetId?: string
damageDealt?: { phys: number; fire: number; ltng: number }
affectedEnemyIds?: string[]
} {
const stats = evaluateDeathSentryStats(trap.slvl, {
fireBlast: options.fireBlastBlvl ?? 0,
lightningSentry: options.lightningSentryBlvl ?? 0,
})
const pixelSize = options.subtilePixelSize ?? 16
const ceRadiusPx = stats.corpseExplosionRadiusSubtiles * pixelSize
// 1. Search for nearest corpse in CE radius
let bestCorpse: SimulationCorpse | null = null
let bestCorpseDistSq = Infinity
for (const c of corpses) {
if (c.consumed) continue
const dx = c.x - trap.x
const dy = c.y - trap.y
const distSq = dx * dx + dy * dy
if (distSq <= ceRadiusPx * ceRadiusPx && distSq < bestCorpseDistSq) {
bestCorpseDistSq = distSq
bestCorpse = c
}
}
if (bestCorpse) {
// Consume corpse
bestCorpse.consumed = true
// Calculate 40-80% corpse life damage (average 60%)
const pct = (stats.corpseExplosionMinPct + stats.corpseExplosionMaxPct) / 200
const rawDamage = Math.floor(bestCorpse.baseMaxHp * pct)
const physDmg = Math.floor(rawDamage * stats.corpseExplosionPhysRatio)
const fireDmg = rawDamage - physDmg
const affectedEnemyIds: string[] = []
for (const e of enemies) {
const dx = e.x - bestCorpse.x
const dy = e.y - bestCorpse.y
if (dx * dx + dy * dy <= ceRadiusPx * ceRadiusPx) {
affectedEnemyIds.push(e.id)
e.currentHp = Math.max(0, e.currentHp - rawDamage)
}
}
return {
action: 'corpse_explosion',
targetId: bestCorpse.id,
damageDealt: { phys: physDmg, fire: fireDmg, ltng: 0 },
affectedEnemyIds,
}
}
// 2. Search for nearest live enemy within lightning range (~480 px)
const maxLtngDistPx = 480
let bestEnemy: SimulationEnemy | null = null
let bestEnemyDistSq = Infinity
for (const e of enemies) {
if (e.currentHp <= 0) continue
const dx = e.x - trap.x
const dy = e.y - trap.y
const distSq = dx * dx + dy * dy
if (distSq <= maxLtngDistPx * maxLtngDistPx && distSq < bestEnemyDistSq) {
bestEnemyDistSq = distSq
bestEnemy = e
}
}
if (bestEnemy) {
const avgLtng = Math.floor((stats.minLtngDamage + stats.maxLtngDamage) / 2)
bestEnemy.currentHp = Math.max(0, bestEnemy.currentHp - avgLtng)
return {
action: 'lightning',
targetId: bestEnemy.id,
damageDealt: { phys: 0, fire: 0, ltng: avgLtng },
affectedEnemyIds: [bestEnemy.id],
}
}
return { action: 'idle' }
}
/**
* Simulate Wake of Fire wave emission with NextDelay protection:
* Each target can only be damaged once per 4 frames by Wake of Fire.
*/
export function simulateWakeOfFireWaveHit(
trap: ActiveTrap,
enemies: SimulationEnemy[],
currentFrame: number,
nextDelayTracker: NextDelayTracker,
options: {
fireBlastBlvl?: number
wakeOfInfernoBlvl?: number
waveRangePx?: number
} = {}
): {
enemiesHit: string[]
totalDamageDealt: number
} {
const stats = evaluateWakeOfFireStats(trap.slvl, {
fireBlast: options.fireBlastBlvl ?? 0,
wakeOfInferno: options.wakeOfInfernoBlvl ?? 0,
})
const rangePx = options.waveRangePx ?? 300
const enemiesHit: string[] = []
let totalDamageDealt = 0
const avgDamage = Math.floor((stats.minDamage + stats.maxDamage) / 2)
for (const e of enemies) {
if (e.currentHp <= 0) continue
const dx = e.x - trap.x
const dy = e.y - trap.y
if (dx * dx + dy * dy <= rangePx * rangePx) {
// Check NextDelay
if (nextDelayTracker.canTakeDamage(e.id, 'wake_of_fire', currentFrame)) {
enemiesHit.push(e.id)
e.currentHp = Math.max(0, e.currentHp - avgDamage)
totalDamageDealt += avgDamage
nextDelayTracker.recordHit(e.id, 'wake_of_fire', currentFrame, stats.nextDelayFrames)
}
}
}
return { enemiesHit, totalDamageDealt }
}
/**
* Calculate Shock Web cluster points dispersed around the target impact point.
*/
export function computeShockWebClusterPoints(
targetX: number,
targetY: number,
slvl: number,
fireBlastBlvl = 0,
radiusPx = 64
): { x: number; y: number }[] {
const stats = evaluateShockWebStats(slvl, { fireBlast: fireBlastBlvl })
const points: { x: number; y: number }[] = []
// Deterministic circular / hexagonal distribution
for (let i = 0; i < stats.missileCount; i++) {
const angle = (2 * Math.PI * i) / stats.missileCount
// Varied distances for natural cluster density
const r = radiusPx * (0.3 + 0.7 * ((i % 3) / 2))
points.push({
x: Math.round(targetX + r * Math.cos(angle)),
y: Math.round(targetY + r * Math.sin(angle)),
})
}
return points
}

View File

@ -16697,6 +16697,637 @@ export const HURRICANETREE_META: MissileMeta = {
],
}
export const FIRETRAUMA_META: MissileMeta = {
name: "firetrauma",
celFile: "expansion/FireTraumaMissile",
width: 512,
height: 10,
directions: 1,
framesPerDirection: 20,
animSpeed: 16,
groups: [
[
[0, 0, 12, 10, -6, -13],
[13, 0, 12, 10, -6, -13],
[26, 0, 12, 10, -6, -13],
[39, 0, 12, 10, -6, -13],
[52, 0, 12, 10, -6, -13],
[65, 0, 12, 10, -6, -13],
[78, 0, 12, 10, -6, -13],
[91, 0, 12, 10, -6, -13],
[104, 0, 12, 10, -6, -13],
[117, 0, 12, 10, -6, -13],
[130, 0, 12, 10, -6, -13],
[143, 0, 12, 10, -6, -13],
[156, 0, 12, 10, -6, -13],
[169, 0, 12, 10, -6, -13],
[182, 0, 12, 10, -6, -13],
[195, 0, 12, 10, -6, -13],
[208, 0, 12, 10, -6, -13],
[221, 0, 12, 10, -6, -13],
[234, 0, 12, 10, -6, -13],
[247, 0, 12, 10, -6, -13]
]
],
}
export const FIRETRAUMAGROUND_META: MissileMeta = {
name: "firetraumaground",
celFile: "expansion/FireTraumaGround",
width: 512,
height: 10,
directions: 1,
framesPerDirection: 1,
animSpeed: 16,
groups: [
[
[0, 0, 11, 10, -5, -13]
]
],
}
export const FIRETRAUMAEXPLODE_META: MissileMeta = {
name: "firetraumaexplode",
celFile: "expansion/FireTraumaXplosion",
width: 512,
height: 451,
directions: 1,
framesPerDirection: 20,
animSpeed: 16,
groups: [
[
[0, 0, 87, 112, -45, -111],
[88, 0, 87, 112, -45, -111],
[176, 0, 87, 112, -45, -111],
[264, 0, 87, 112, -45, -111],
[352, 0, 87, 112, -45, -111],
[0, 113, 87, 112, -45, -111],
[88, 113, 87, 112, -45, -111],
[176, 113, 87, 112, -45, -111],
[264, 113, 87, 112, -45, -111],
[352, 113, 87, 112, -45, -111],
[0, 226, 87, 112, -45, -111],
[88, 226, 87, 112, -45, -111],
[176, 226, 87, 112, -45, -111],
[264, 226, 87, 112, -45, -111],
[352, 226, 87, 112, -45, -111],
[0, 339, 87, 112, -45, -111],
[88, 339, 87, 112, -45, -111],
[176, 339, 87, 112, -45, -111],
[264, 339, 87, 112, -45, -111],
[352, 339, 87, 112, -45, -111]
]
],
}
export const SHOCKFIELDAIR_META: MissileMeta = {
name: "shockfieldair",
celFile: "expansion/caltrops",
width: 512,
height: 65,
directions: 32,
framesPerDirection: 8,
animSpeed: 16,
groups: [
[
[0, 0, 11, 10, -5, -1],
[12, 0, 11, 10, -5, -1],
[24, 0, 11, 10, -5, -1],
[36, 0, 11, 10, -5, -1],
[48, 0, 11, 10, -5, -1],
[60, 0, 11, 10, -5, -1],
[72, 0, 11, 10, -5, -1],
[84, 0, 11, 10, -5, -1]
],
[
[96, 0, 10, 10, -2, 0],
[107, 0, 10, 10, -2, 0],
[118, 0, 10, 10, -2, 0],
[129, 0, 10, 10, -2, 0],
[140, 0, 10, 10, -2, 0],
[151, 0, 10, 10, -2, 0],
[162, 0, 10, 10, -2, 0],
[173, 0, 10, 10, -2, 0]
],
[
[184, 0, 9, 9, -5, 2],
[194, 0, 9, 9, -5, 2],
[204, 0, 9, 9, -5, 2],
[214, 0, 9, 9, -5, 2],
[224, 0, 9, 9, -5, 2],
[234, 0, 9, 9, -5, 2],
[244, 0, 9, 9, -5, 2],
[254, 0, 9, 9, -5, 2]
],
[
[264, 0, 11, 10, -8, 0],
[276, 0, 11, 10, -8, 0],
[288, 0, 11, 10, -8, 0],
[300, 0, 11, 10, -8, 0],
[312, 0, 11, 10, -8, 0],
[324, 0, 11, 10, -8, 0],
[336, 0, 11, 10, -8, 0],
[348, 0, 11, 10, -8, 0]
],
[
[360, 0, 7, 10, -5, -1],
[368, 0, 7, 10, -5, -1],
[376, 0, 7, 10, -5, -1],
[384, 0, 7, 10, -5, -1],
[392, 0, 7, 10, -5, -1],
[400, 0, 7, 10, -5, -1],
[408, 0, 7, 10, -5, -1],
[416, 0, 7, 10, -5, -1]
],
[
[424, 0, 10, 9, -3, 0],
[435, 0, 10, 9, -3, 0],
[446, 0, 10, 9, -3, 0],
[457, 0, 10, 9, -3, 0],
[468, 0, 10, 9, -3, 0],
[479, 0, 10, 9, -3, 0],
[490, 0, 10, 9, -3, 0],
[501, 0, 10, 9, -3, 0]
],
[
[0, 11, 8, 10, -2, 1],
[9, 11, 8, 10, -2, 1],
[18, 11, 8, 10, -2, 1],
[27, 11, 8, 10, -2, 1],
[36, 11, 8, 10, -2, 1],
[45, 11, 8, 10, -2, 1],
[54, 11, 8, 10, -2, 1],
[63, 11, 8, 10, -2, 1]
],
[
[72, 11, 10, 10, -7, 1],
[83, 11, 10, 10, -7, 1],
[94, 11, 10, 10, -7, 1],
[105, 11, 10, 10, -7, 1],
[116, 11, 10, 10, -7, 1],
[127, 11, 10, 10, -7, 1],
[138, 11, 10, 10, -7, 1],
[149, 11, 10, 10, -7, 1]
],
[
[160, 11, 9, 10, -5, -1],
[170, 11, 9, 10, -5, -1],
[180, 11, 9, 10, -5, -1],
[190, 11, 9, 10, -5, -1],
[200, 11, 9, 10, -5, -1],
[210, 11, 9, 10, -5, -1],
[220, 11, 9, 10, -5, -1],
[230, 11, 9, 10, -5, -1]
],
[
[240, 11, 10, 10, -3, -1],
[251, 11, 10, 10, -3, -1],
[262, 11, 10, 10, -3, -1],
[273, 11, 10, 10, -3, -1],
[284, 11, 10, 10, -3, -1],
[295, 11, 10, 10, -3, -1],
[306, 11, 10, 10, -3, -1],
[317, 11, 10, 10, -3, -1]
],
[
[328, 11, 11, 10, -3, 0],
[340, 11, 11, 10, -3, 0],
[352, 11, 11, 10, -3, 0],
[364, 11, 11, 10, -3, 0],
[376, 11, 11, 10, -3, 0],
[388, 11, 11, 10, -3, 0],
[400, 11, 11, 10, -3, 0],
[412, 11, 11, 10, -3, 0]
],
[
[424, 11, 10, 10, -3, 1],
[435, 11, 10, 10, -3, 1],
[446, 11, 10, 10, -3, 1],
[457, 11, 10, 10, -3, 1],
[468, 11, 10, 10, -3, 1],
[479, 11, 10, 10, -3, 1],
[490, 11, 10, 10, -3, 1],
[501, 11, 10, 10, -3, 1]
],
[
[0, 22, 9, 10, -3, 1],
[10, 22, 9, 10, -3, 1],
[20, 22, 9, 10, -3, 1],
[30, 22, 9, 10, -3, 1],
[40, 22, 9, 10, -3, 1],
[50, 22, 9, 10, -3, 1],
[60, 22, 9, 10, -3, 1],
[70, 22, 9, 10, -3, 1]
],
[
[80, 22, 10, 10, -6, 1],
[91, 22, 10, 10, -6, 1],
[102, 22, 10, 10, -6, 1],
[113, 22, 10, 10, -6, 1],
[124, 22, 10, 10, -6, 1],
[135, 22, 10, 10, -6, 1],
[146, 22, 10, 10, -6, 1],
[157, 22, 10, 10, -6, 1]
],
[
[168, 22, 10, 9, -8, 1],
[179, 22, 10, 9, -8, 1],
[190, 22, 10, 9, -8, 1],
[201, 22, 10, 9, -8, 1],
[212, 22, 10, 9, -8, 1],
[223, 22, 10, 9, -8, 1],
[234, 22, 10, 9, -8, 1],
[245, 22, 10, 9, -8, 1]
],
[
[256, 22, 9, 9, -6, 0],
[266, 22, 9, 9, -6, 0],
[276, 22, 9, 9, -6, 0],
[286, 22, 9, 9, -6, 0],
[296, 22, 9, 9, -6, 0],
[306, 22, 9, 9, -6, 0],
[316, 22, 9, 9, -6, 0],
[326, 22, 9, 9, -6, 0]
],
[
[336, 22, 8, 10, -5, -1],
[345, 22, 8, 10, -5, -1],
[354, 22, 8, 10, -5, -1],
[363, 22, 8, 10, -5, -1],
[372, 22, 8, 10, -5, -1],
[381, 22, 8, 10, -5, -1],
[390, 22, 8, 10, -5, -1],
[399, 22, 8, 10, -5, -1]
],
[
[408, 22, 9, 10, -5, -1],
[418, 22, 9, 10, -5, -1],
[428, 22, 9, 10, -5, -1],
[438, 22, 9, 10, -5, -1],
[448, 22, 9, 10, -5, -1],
[458, 22, 9, 10, -5, -1],
[468, 22, 9, 10, -5, -1],
[478, 22, 9, 10, -5, -1]
],
[
[488, 22, 10, 10, -4, -1],
[499, 22, 10, 10, -4, -1],
[0, 33, 10, 10, -4, -1],
[11, 33, 10, 10, -4, -1],
[22, 33, 10, 10, -4, -1],
[33, 33, 10, 10, -4, -1],
[44, 33, 10, 10, -4, -1],
[55, 33, 10, 10, -4, -1]
],
[
[66, 33, 10, 9, -3, 0],
[77, 33, 10, 9, -3, 0],
[88, 33, 10, 9, -3, 0],
[99, 33, 10, 9, -3, 0],
[110, 33, 10, 9, -3, 0],
[121, 33, 10, 9, -3, 0],
[132, 33, 10, 9, -3, 0],
[143, 33, 10, 9, -3, 0]
],
[
[154, 33, 9, 10, -2, 0],
[164, 33, 9, 10, -2, 0],
[174, 33, 9, 10, -2, 0],
[184, 33, 9, 10, -2, 0],
[194, 33, 9, 10, -2, 0],
[204, 33, 9, 10, -2, 0],
[214, 33, 9, 10, -2, 0],
[224, 33, 9, 10, -2, 0]
],
[
[234, 33, 9, 10, -2, 0],
[244, 33, 9, 10, -2, 0],
[254, 33, 9, 10, -2, 0],
[264, 33, 9, 10, -2, 0],
[274, 33, 9, 10, -2, 0],
[284, 33, 9, 10, -2, 0],
[294, 33, 9, 10, -2, 0],
[304, 33, 9, 10, -2, 0]
],
[
[314, 33, 10, 10, -3, 0],
[325, 33, 10, 10, -3, 0],
[336, 33, 10, 10, -3, 0],
[347, 33, 10, 10, -3, 0],
[358, 33, 10, 10, -3, 0],
[369, 33, 10, 10, -3, 0],
[380, 33, 10, 10, -3, 0],
[391, 33, 10, 10, -3, 0]
],
[
[402, 33, 9, 10, -2, 1],
[412, 33, 9, 10, -2, 1],
[422, 33, 9, 10, -2, 1],
[432, 33, 9, 10, -2, 1],
[442, 33, 9, 10, -2, 1],
[452, 33, 9, 10, -2, 1],
[462, 33, 9, 10, -2, 1],
[472, 33, 9, 10, -2, 1]
],
[
[482, 33, 8, 10, -3, 1],
[491, 33, 8, 10, -3, 1],
[500, 33, 8, 10, -3, 1],
[0, 44, 8, 10, -3, 1],
[9, 44, 8, 10, -3, 1],
[18, 44, 8, 10, -3, 1],
[27, 44, 8, 10, -3, 1],
[36, 44, 8, 10, -3, 1]
],
[
[45, 44, 10, 10, -5, 1],
[56, 44, 10, 10, -5, 1],
[67, 44, 10, 10, -5, 1],
[78, 44, 10, 10, -5, 1],
[89, 44, 10, 10, -5, 1],
[100, 44, 10, 10, -5, 1],
[111, 44, 10, 10, -5, 1],
[122, 44, 10, 10, -5, 1]
],
[
[133, 44, 10, 10, -6, 1],
[144, 44, 10, 10, -6, 1],
[155, 44, 10, 10, -6, 1],
[166, 44, 10, 10, -6, 1],
[177, 44, 10, 10, -6, 1],
[188, 44, 10, 10, -6, 1],
[199, 44, 10, 10, -6, 1],
[210, 44, 10, 10, -6, 1]
],
[
[221, 44, 10, 9, -7, 1],
[232, 44, 10, 9, -7, 1],
[243, 44, 10, 9, -7, 1],
[254, 44, 10, 9, -7, 1],
[265, 44, 10, 9, -7, 1],
[276, 44, 10, 9, -7, 1],
[287, 44, 10, 9, -7, 1],
[298, 44, 10, 9, -7, 1]
],
[
[309, 44, 9, 10, -7, 1],
[319, 44, 9, 10, -7, 1],
[329, 44, 9, 10, -7, 1],
[339, 44, 9, 10, -7, 1],
[349, 44, 9, 10, -7, 1],
[359, 44, 9, 10, -7, 1],
[369, 44, 9, 10, -7, 1],
[379, 44, 9, 10, -7, 1]
],
[
[389, 44, 10, 10, -8, 0],
[400, 44, 10, 10, -8, 0],
[411, 44, 10, 10, -8, 0],
[422, 44, 10, 10, -8, 0],
[433, 44, 10, 10, -8, 0],
[444, 44, 10, 10, -8, 0],
[455, 44, 10, 10, -8, 0],
[466, 44, 10, 10, -8, 0]
],
[
[477, 44, 9, 10, -7, -1],
[487, 44, 9, 10, -7, -1],
[497, 44, 9, 10, -7, -1],
[0, 55, 9, 10, -7, -1],
[10, 55, 9, 10, -7, -1],
[20, 55, 9, 10, -7, -1],
[30, 55, 9, 10, -7, -1],
[40, 55, 9, 10, -7, -1]
],
[
[50, 55, 9, 10, -6, -1],
[60, 55, 9, 10, -6, -1],
[70, 55, 9, 10, -6, -1],
[80, 55, 9, 10, -6, -1],
[90, 55, 9, 10, -6, -1],
[100, 55, 9, 10, -6, -1],
[110, 55, 9, 10, -6, -1],
[120, 55, 9, 10, -6, -1]
]
],
}
export const SHOCKFIELDGROUND_META: MissileMeta = {
name: "shockfieldground",
celFile: "Expansion/ShockField",
width: 1024,
height: 647,
directions: 8,
framesPerDirection: 10,
animSpeed: 16,
groups: [
[
[0, 0, 102, 60, -56, -32],
[103, 0, 102, 60, -56, -32],
[206, 0, 102, 60, -56, -32],
[309, 0, 102, 60, -56, -32],
[412, 0, 102, 60, -56, -32],
[515, 0, 102, 60, -56, -32],
[618, 0, 102, 60, -56, -32],
[721, 0, 102, 60, -56, -32],
[824, 0, 102, 60, -56, -32],
[0, 61, 102, 60, -56, -32]
],
[
[103, 61, 95, 60, -40, -31],
[199, 61, 95, 60, -40, -31],
[295, 61, 95, 60, -40, -31],
[391, 61, 95, 60, -40, -31],
[487, 61, 95, 60, -40, -31],
[583, 61, 95, 60, -40, -31],
[679, 61, 95, 60, -40, -31],
[775, 61, 95, 60, -40, -31],
[871, 61, 95, 60, -40, -31],
[0, 122, 95, 60, -40, -31]
],
[
[96, 122, 100, 77, -43, -31],
[197, 122, 100, 77, -43, -31],
[298, 122, 100, 77, -43, -31],
[399, 122, 100, 77, -43, -31],
[500, 122, 100, 77, -43, -31],
[601, 122, 100, 77, -43, -31],
[702, 122, 100, 77, -43, -31],
[803, 122, 100, 77, -43, -31],
[904, 122, 100, 77, -43, -31],
[0, 200, 100, 77, -43, -31]
],
[
[101, 200, 96, 78, -49, -33],
[198, 200, 96, 78, -49, -33],
[295, 200, 96, 78, -49, -33],
[392, 200, 96, 78, -49, -33],
[489, 200, 96, 78, -49, -33],
[586, 200, 96, 78, -49, -33],
[683, 200, 96, 78, -49, -33],
[780, 200, 96, 78, -49, -33],
[877, 200, 96, 78, -49, -33],
[0, 279, 96, 78, -49, -33]
],
[
[97, 279, 95, 77, -41, -34],
[193, 279, 95, 77, -41, -34],
[289, 279, 95, 77, -41, -34],
[385, 279, 95, 77, -41, -34],
[481, 279, 95, 77, -41, -34],
[577, 279, 95, 77, -41, -34],
[673, 279, 95, 77, -41, -34],
[769, 279, 95, 77, -41, -34],
[865, 279, 95, 77, -41, -34],
[0, 358, 95, 77, -41, -34]
],
[
[96, 358, 105, 56, -50, -27],
[202, 358, 105, 56, -50, -27],
[308, 358, 105, 56, -50, -27],
[414, 358, 105, 56, -50, -27],
[520, 358, 105, 56, -50, -27],
[626, 358, 105, 56, -50, -27],
[732, 358, 105, 56, -50, -27],
[838, 358, 105, 56, -50, -27],
[0, 436, 105, 56, -50, -27],
[106, 436, 105, 56, -50, -27]
],
[
[212, 436, 93, 70, -53, -33],
[306, 436, 93, 70, -53, -33],
[400, 436, 93, 70, -53, -33],
[494, 436, 93, 70, -53, -33],
[588, 436, 93, 70, -53, -33],
[682, 436, 93, 70, -53, -33],
[776, 436, 93, 70, -53, -33],
[870, 436, 93, 70, -53, -33],
[0, 507, 93, 70, -53, -33],
[94, 507, 93, 70, -53, -33]
],
[
[188, 507, 101, 69, -55, -32],
[290, 507, 101, 69, -55, -32],
[392, 507, 101, 69, -55, -32],
[494, 507, 101, 69, -55, -32],
[596, 507, 101, 69, -55, -32],
[698, 507, 101, 69, -55, -32],
[800, 507, 101, 69, -55, -32],
[902, 507, 101, 69, -55, -32],
[0, 578, 101, 69, -55, -32],
[102, 578, 101, 69, -55, -32]
]
],
}
export const BLADEFURY_META: MissileMeta = {
name: "bladefury",
celFile: "Expansion/bladefury",
width: 512,
height: 6,
directions: 1,
framesPerDirection: 7,
animSpeed: 16,
groups: [
[
[0, 0, 13, 6, -6, -34],
[14, 0, 13, 6, -6, -34],
[28, 0, 13, 6, -6, -34],
[42, 0, 13, 6, -6, -34],
[56, 0, 13, 6, -6, -34],
[70, 0, 13, 6, -6, -34],
[84, 0, 13, 6, -6, -34]
]
],
}
export const BLADEFURYB_META: MissileMeta = {
name: "bladefuryb",
celFile: "Expansion/bladefury_B",
width: 512,
height: 9,
directions: 1,
framesPerDirection: 7,
animSpeed: 16,
groups: [
[
[0, 0, 20, 9, -10, -35],
[21, 0, 20, 9, -10, -35],
[42, 0, 20, 9, -10, -35],
[63, 0, 20, 9, -10, -35],
[84, 0, 20, 9, -10, -35],
[105, 0, 20, 9, -10, -35],
[126, 0, 20, 9, -10, -35]
]
],
}
export const BLADEFURYC_META: MissileMeta = {
name: "bladefuryc",
celFile: "Expansion/bladefury_C",
width: 512,
height: 11,
directions: 1,
framesPerDirection: 7,
animSpeed: 16,
groups: [
[
[0, 0, 24, 11, -12, -36],
[25, 0, 24, 11, -12, -36],
[50, 0, 24, 11, -12, -36],
[75, 0, 24, 11, -12, -36],
[100, 0, 24, 11, -12, -36],
[125, 0, 24, 11, -12, -36],
[150, 0, 24, 11, -12, -36]
]
],
}
export const DEATHSENTRYEXPLODE_META: MissileMeta = {
name: "deathsentryexplode",
celFile: "Expansion/dethsentry02",
width: 512,
height: 275,
directions: 1,
framesPerDirection: 12,
animSpeed: 16,
groups: [
[
[0, 0, 115, 91, -66, -66],
[116, 0, 115, 91, -66, -66],
[232, 0, 115, 91, -66, -66],
[348, 0, 115, 91, -66, -66],
[0, 92, 115, 91, -66, -66],
[116, 92, 115, 91, -66, -66],
[232, 92, 115, 91, -66, -66],
[348, 92, 115, 91, -66, -66],
[0, 184, 115, 91, -66, -66],
[116, 184, 115, 91, -66, -66],
[232, 184, 115, 91, -66, -66],
[348, 184, 115, 91, -66, -66]
]
],
}
export const BLADESHIELDMISSILE_META: MissileMeta = {
name: "bladeshieldmissile",
celFile: "Expansion/blade_shield_missile",
width: 512,
height: 11,
directions: 1,
framesPerDirection: 5,
animSpeed: 16,
groups: [
[
[0, 0, 20, 11, -10, -7],
[21, 0, 20, 11, -10, -7],
[42, 0, 20, 11, -10, -7],
[63, 0, 20, 11, -10, -7],
[84, 0, 20, 11, -10, -7]
]
],
}
export const BLIZZARD4_META: MissileMeta = {
...BLIZZARD3_META,
name: 'blizzard4',
@ -16939,6 +17570,16 @@ export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freez
'hurricaneswoosh': HURRICANESWOOSH_META,
'hurricanerock': HURRICANEROCK_META,
'hurricanetree': HURRICANETREE_META,
'firetrauma': FIRETRAUMA_META,
'firetraumaground': FIRETRAUMAGROUND_META,
'firetraumaexplode': FIRETRAUMAEXPLODE_META,
'shockfieldair': SHOCKFIELDAIR_META,
'shockfieldground': SHOCKFIELDGROUND_META,
'bladefury': BLADEFURY_META,
'bladefuryb': BLADEFURYB_META,
'bladefuryc': BLADEFURYC_META,
'deathsentryexplode': DEATHSENTRYEXPLODE_META,
'bladeshieldmissile': BLADESHIELDMISSILE_META,
'revivesmall': REVIVESMALL_META,
'revivemedium': REVIVEMEDIUM_META,
'revivelarge': REVIVELARGE_META,

View File

@ -22058,6 +22058,320 @@ export const DIREWOLFCHARGED_OVERLAY_META: OverlayMeta = {
]
}
export const BLADE_SHIELD_META: OverlayMeta = {
"name": "blade_shield",
"celFile": "Expansion/blade_shield",
"width": 512,
"height": 200,
"directions": 1,
"frames": 20,
"framesPerDirection": 20,
"animRate": 16,
"trans": 3,
"preDraw": false,
"lightRadius": 0,
"lightColor": [
255,
255,
255
],
"box": {
"left": -33,
"top": -68,
"width": 64,
"height": 66
},
"groups": [
[
[
0,
0,
64,
66,
-33,
-68
],
[
65,
0,
64,
66,
-33,
-68
],
[
130,
0,
64,
66,
-33,
-68
],
[
195,
0,
64,
66,
-33,
-68
],
[
260,
0,
64,
66,
-33,
-68
],
[
325,
0,
64,
66,
-33,
-68
],
[
390,
0,
64,
66,
-33,
-68
],
[
0,
67,
64,
66,
-33,
-68
],
[
65,
67,
64,
66,
-33,
-68
],
[
130,
67,
64,
66,
-33,
-68
],
[
195,
67,
64,
66,
-33,
-68
],
[
260,
67,
64,
66,
-33,
-68
],
[
325,
67,
64,
66,
-33,
-68
],
[
390,
67,
64,
66,
-33,
-68
],
[
0,
134,
64,
66,
-33,
-68
],
[
65,
134,
64,
66,
-33,
-68
],
[
130,
134,
64,
66,
-33,
-68
],
[
195,
134,
64,
66,
-33,
-68
],
[
260,
134,
64,
66,
-33,
-68
],
[
325,
134,
64,
66,
-33,
-68
]
]
]
}
export const DEATHSENTRY_EXPLODE_META: OverlayMeta = {
"name": "deathsentry_explode",
"celFile": "expansion/dethsentry01",
"width": 512,
"height": 547,
"directions": 1,
"frames": 12,
"framesPerDirection": 12,
"animRate": 16,
"trans": 3,
"preDraw": false,
"lightRadius": 0,
"lightColor": [
255,
255,
255
],
"box": {
"left": -73,
"top": -93,
"width": 130,
"height": 136
},
"groups": [
[
[
0,
0,
130,
136,
-73,
-93
],
[
131,
0,
130,
136,
-73,
-93
],
[
262,
0,
130,
136,
-73,
-93
],
[
0,
137,
130,
136,
-73,
-93
],
[
131,
137,
130,
136,
-73,
-93
],
[
262,
137,
130,
136,
-73,
-93
],
[
0,
274,
130,
136,
-73,
-93
],
[
131,
274,
130,
136,
-73,
-93
],
[
262,
274,
130,
136,
-73,
-93
],
[
0,
411,
130,
136,
-73,
-93
],
[
131,
411,
130,
136,
-73,
-93
],
[
262,
411,
130,
136,
-73,
-93
]
]
]
}
// Backward compatibility aliases for primary aura constants
export const MIGHT_AURA_META: OverlayMeta = MIGHT_FRONT_AURA_META
@ -22297,6 +22611,8 @@ export const ALL_OVERLAY_NAMES: readonly string[] = [
"feralrage4back",
"feralrage5back",
"direwolfcharged",
"blade_shield",
"deathsentry_explode",
]
export const OVERLAY_METAS: Readonly<Record<string, OverlayMeta>> = {
@ -22899,4 +23215,12 @@ export const OVERLAY_METAS: Readonly<Record<string, OverlayMeta>> = {
"expansion/direwolfcharged": DIREWOLFCHARGED_OVERLAY_META,
"dire_wolf_charged": DIREWOLFCHARGED_OVERLAY_META,
"direwolf_charged": DIREWOLFCHARGED_OVERLAY_META,
"blade_shield": BLADE_SHIELD_META,
"Expansion/blade_shield": BLADE_SHIELD_META,
"expansion/blade_shield": BLADE_SHIELD_META,
"bladeshield": BLADE_SHIELD_META,
"deathsentry_explode": DEATHSENTRY_EXPLODE_META,
"expansion/dethsentry01": DEATHSENTRY_EXPLODE_META,
"deathsentry": DEATHSENTRY_EXPLODE_META,
"dethsentry01": DEATHSENTRY_EXPLODE_META,
}

View File

@ -0,0 +1,560 @@
/**
* Adversarial Stress & Ground Truth Parity Test Suite — Assassin Traps (10 skills)
*
* Validates 1.13c Diablo II mechanics:
* 1. Global 5-trap cap on PetType `assassintrap` with strict FIFO eviction.
* 2. NextDelay mechanics (Wake of Fire 4-frame immunity, Shock Web 25-frame immunity).
* 3. Death Sentry dual-mode AI (corpse prioritization, 50/50 phys/fire split, radius progression, shot scaling).
* 4. Blade skills weapon damage scaling (3/8 for Sentinel, 3/4 for Fury, 1/4 for Shield).
* 5. Shock Web missile cluster dispersion and synergy scaling.
* 6. Sentry shot depletion and life cycles.
*
* Gitea Milestone: M26 (Issue #473)
*/
import { describe, expect, it } from 'vitest'
import {
ASSASSIN_TRAP_SKILL_IDS,
MAX_ACTIVE_TRAPS,
BLADE_SRC_DAM,
NEXT_DELAY_FRAMES,
TrapManager,
NextDelayTracker,
computeBladeWeaponDamage,
evaluateFireBlastStats,
evaluateShockWebStats,
evaluateBladeSentinelStats,
evaluateChargedBoltSentryStats,
evaluateWakeOfFireStats,
evaluateBladeFuryStats,
evaluateLightningSentryStats,
evaluateWakeOfInfernoStats,
evaluateDeathSentryStats,
evaluateBladeShieldStats,
simulateDeathSentryAI,
simulateWakeOfFireWaveHit,
computeShockWebClusterPoints,
type SimulationCorpse,
type SimulationEnemy,
} from '../../../src/game/skills/assassin-traps.ts'
describe('Assassin Traps Ground Truth & Adversarial Suite (Milestone M26)', () => {
describe('1. Global Trap Cap & FIFO Eviction (PetType: assassintrap, Limit: 5)', () => {
it('allows up to 5 traps without eviction', () => {
const manager = new TrapManager()
const ownerId = 'assassin_1'
// Summon 5 different traps
const trap1 = manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.CHARGED_BOLT_SENTRY,
summonMon: 'chargeboltsentry',
x: 100,
y: 100,
slvl: 20,
shots: 5,
currentFrame: 10,
})
expect(trap1.evictedTrap).toBeUndefined()
expect(manager.getActiveTrapCount(ownerId)).toBe(1)
manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.WAKE_OF_FIRE,
summonMon: 'wakeofdestruction',
x: 120,
y: 100,
slvl: 20,
shots: 5,
currentFrame: 20,
})
manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY,
summonMon: 'lightningsentry',
x: 140,
y: 100,
slvl: 20,
shots: 10,
currentFrame: 30,
})
manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.WAKE_OF_INFERNO,
summonMon: 'infernosentry',
x: 160,
y: 100,
slvl: 20,
shots: 10,
currentFrame: 40,
})
const trap5 = manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY,
summonMon: 'deathsentry',
x: 180,
y: 100,
slvl: 20,
shots: 5,
currentFrame: 50,
})
expect(trap5.evictedTrap).toBeUndefined()
expect(manager.getActiveTrapCount(ownerId)).toBe(5)
})
it('strictly evicts the oldest trap (FIFO) when a 6th trap is summoned', () => {
const manager = new TrapManager()
const ownerId = 'assassin_1'
// Register 5 traps
const t1 = manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY,
summonMon: 'lightningsentry',
x: 10,
y: 10,
slvl: 20,
shots: 10,
currentFrame: 10,
}).trap
const t2 = manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY,
summonMon: 'lightningsentry',
x: 20,
y: 10,
slvl: 20,
shots: 10,
currentFrame: 20,
}).trap
manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY,
summonMon: 'lightningsentry',
x: 30,
y: 10,
slvl: 20,
shots: 10,
currentFrame: 30,
})
manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY,
summonMon: 'deathsentry',
x: 40,
y: 10,
slvl: 20,
shots: 5,
currentFrame: 40,
})
manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY,
summonMon: 'deathsentry',
x: 50,
y: 10,
slvl: 20,
shots: 5,
currentFrame: 50,
})
expect(manager.getActiveTrapCount(ownerId)).toBe(5)
// Summon 6th trap -> t1 (the oldest) MUST be evicted!
const res6 = manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.WAKE_OF_FIRE,
summonMon: 'wakeofdestruction',
x: 60,
y: 10,
slvl: 20,
shots: 5,
currentFrame: 60,
})
expect(res6.evictedTrap).toBeDefined()
expect(res6.evictedTrap?.id).toBe(t1.id)
expect(manager.getActiveTrapCount(ownerId)).toBe(5)
// Active traps should NOT contain t1
const activeIds = manager.getActiveTraps(ownerId).map(t => t.id)
expect(activeIds).not.toContain(t1.id)
expect(activeIds).toContain(res6.trap.id)
// Summon 7th trap -> t2 MUST be evicted!
const res7 = manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.CHARGED_BOLT_SENTRY,
summonMon: 'chargeboltsentry',
x: 70,
y: 10,
slvl: 20,
shots: 5,
currentFrame: 70,
})
expect(res7.evictedTrap?.id).toBe(t2.id)
expect(manager.getActiveTrapCount(ownerId)).toBe(5)
})
it('isolates trap quotas per player/owner in multiplayer arena', () => {
const manager = new TrapManager()
const p1 = 'assassin_alice'
const p2 = 'assassin_bob'
for (let i = 0; i < 5; i++) {
manager.registerTrap({
ownerId: p1,
skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY,
summonMon: 'lightningsentry',
x: i * 10,
y: 0,
slvl: 20,
shots: 10,
currentFrame: 0,
})
manager.registerTrap({
ownerId: p2,
skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY,
summonMon: 'deathsentry',
x: i * 10,
y: 50,
slvl: 20,
shots: 5,
currentFrame: 0,
})
}
expect(manager.getActiveTrapCount(p1)).toBe(5)
expect(manager.getActiveTrapCount(p2)).toBe(5)
// Summon 6th trap for p1; only p1's trap is evicted, p2's remain intact
const res = manager.registerTrap({
ownerId: p1,
skillId: ASSASSIN_TRAP_SKILL_IDS.WAKE_OF_FIRE,
summonMon: 'wakeofdestruction',
x: 999,
y: 0,
slvl: 20,
shots: 5,
currentFrame: 100,
})
expect(res.evictedTrap?.ownerId).toBe(p1)
expect(manager.getActiveTrapCount(p1)).toBe(5)
expect(manager.getActiveTrapCount(p2)).toBe(5)
})
it('automatically destroys trap when shot count reaches zero', () => {
const manager = new TrapManager()
const ownerId = 'assassin_1'
const { trap } = manager.registerTrap({
ownerId,
skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY,
summonMon: 'deathsentry',
x: 0,
y: 0,
slvl: 1,
shots: 3,
currentFrame: 0,
})
expect(manager.getActiveTrapCount(ownerId)).toBe(1)
const s1 = manager.consumeShot(trap.id)
expect(s1.remaining).toBe(2)
expect(s1.destroyed).toBe(false)
expect(manager.getActiveTrapCount(ownerId)).toBe(1)
const s2 = manager.consumeShot(trap.id)
expect(s2.remaining).toBe(1)
expect(s2.destroyed).toBe(false)
const s3 = manager.consumeShot(trap.id)
expect(s3.remaining).toBe(0)
expect(s3.destroyed).toBe(true)
expect(manager.getActiveTrapCount(ownerId)).toBe(0)
})
})
describe('2. NextDelay Mechanics (Wake of Fire & Shock Web)', () => {
it('enforces 4-frame damage immunity for Wake of Fire', () => {
const tracker = new NextDelayTracker()
const enemyId = 'monster_1'
// At frame 100, monster is eligible
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 100)).toBe(true)
// Hit monster at frame 100 with NextDelay 4
tracker.recordHit(enemyId, 'wake_of_fire', 100, NEXT_DELAY_FRAMES.WAKE_OF_FIRE)
// Monster must be immune at frame 100, 101, 102, 103
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 100)).toBe(false)
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 101)).toBe(false)
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 102)).toBe(false)
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 103)).toBe(false)
// At frame 104, NextDelay window has expired (100 + 4 = 104)
expect(tracker.canTakeDamage(enemyId, 'wake_of_fire', 104)).toBe(true)
})
it('enforces 25-frame damage immunity for Shock Web', () => {
const tracker = new NextDelayTracker()
const enemyId = 'monster_boss'
expect(tracker.canTakeDamage(enemyId, 'shock_web', 500)).toBe(true)
tracker.recordHit(enemyId, 'shock_web', 500, NEXT_DELAY_FRAMES.SHOCK_WEB)
// Immune throughout frames 500..524
expect(tracker.canTakeDamage(enemyId, 'shock_web', 510)).toBe(false)
expect(tracker.canTakeDamage(enemyId, 'shock_web', 524)).toBe(false)
// Vulnerable again at frame 525 (500 + 25 = 525)
expect(tracker.canTakeDamage(enemyId, 'shock_web', 525)).toBe(true)
})
it('simulates Wake of Fire wave hit against grouped enemies with NextDelay', () => {
const tracker = new NextDelayTracker()
const enemies: SimulationEnemy[] = [
{ id: 'm1', x: 100, y: 100, currentHp: 500, maxHp: 500 },
{ id: 'm2', x: 150, y: 100, currentHp: 500, maxHp: 500 },
]
const trap: any = {
id: 'wof_1',
x: 0,
y: 100,
slvl: 20,
}
// First wave at frame 0
const hit0 = simulateWakeOfFireWaveHit(trap, enemies, 0, tracker)
expect(hit0.enemiesHit).toContain('m1')
expect(hit0.enemiesHit).toContain('m2')
expect(enemies[0]!.currentHp).toBeLessThan(500)
// Rapid overlap wave at frame 2 (within 4-frame NextDelay window) -> deals 0 damage!
const hpBefore = enemies[0]!.currentHp
const hit2 = simulateWakeOfFireWaveHit(trap, enemies, 2, tracker)
expect(hit2.enemiesHit).toHaveLength(0)
expect(hit2.totalDamageDealt).toBe(0)
expect(enemies[0]!.currentHp).toBe(hpBefore)
// Next wave at frame 5 (> 4 frames) -> hits again!
const hit5 = simulateWakeOfFireWaveHit(trap, enemies, 5, tracker)
expect(hit5.enemiesHit).toContain('m1')
expect(enemies[0]!.currentHp).toBeLessThan(hpBefore)
})
})
describe('3. Death Sentry Dual-Mode Corpse Explosion AI', () => {
it('prioritizes Corpse Explosion over Lightning when a corpse is in radius', () => {
const trap: any = { id: 'ds_1', x: 100, y: 100, slvl: 20 }
const corpses: SimulationCorpse[] = [
{ id: 'corpse_1', x: 120, y: 100, baseMaxHp: 1000, consumed: false },
]
const enemies: SimulationEnemy[] = [
{ id: 'live_mon', x: 130, y: 100, currentHp: 800, maxHp: 800 },
]
const decision = simulateDeathSentryAI(trap, corpses, enemies)
expect(decision.action).toBe('corpse_explosion')
expect(decision.targetId).toBe('corpse_1')
expect(corpses[0]!.consumed).toBe(true)
// 40-80% of 1000 HP -> 600 damage avg (300 phys, 300 fire)
expect(decision.damageDealt?.phys).toBe(300)
expect(decision.damageDealt?.fire).toBe(300)
expect(decision.damageDealt?.ltng).toBe(0)
expect(enemies[0]!.currentHp).toBe(200)
})
it('falls back to piercing lightning bolt when no valid corpse exists in radius', () => {
const trap: any = { id: 'ds_1', x: 0, y: 0, slvl: 20 }
const corpses: SimulationCorpse[] = [
// Corpse is far away outside radius (> 29 subtiles * 16px = 464px)
{ id: 'corpse_far', x: 900, y: 900, baseMaxHp: 1000, consumed: false },
]
const enemies: SimulationEnemy[] = [
{ id: 'near_mon', x: 100, y: 0, currentHp: 500, maxHp: 500 },
]
const decision = simulateDeathSentryAI(trap, corpses, enemies)
expect(decision.action).toBe('lightning')
expect(decision.targetId).toBe('near_mon')
expect(decision.damageDealt?.ltng).toBeGreaterThan(0)
expect(decision.damageDealt?.phys).toBe(0)
expect(decision.damageDealt?.fire).toBe(0)
expect(corpses[0]!.consumed).toBe(false)
})
it('evaluates Death Sentry shot count scaling with Fire Blast synergy', () => {
// Base shots: 5. +1 shot per 3 hard points of Fire Blast
const s0 = evaluateDeathSentryStats(20, { fireBlast: 0 })
expect(s0.shots).toBe(5)
const s2 = evaluateDeathSentryStats(20, { fireBlast: 2 })
expect(s2.shots).toBe(5)
const s3 = evaluateDeathSentryStats(20, { fireBlast: 3 })
expect(s3.shots).toBe(6)
const s9 = evaluateDeathSentryStats(20, { fireBlast: 9 })
expect(s9.shots).toBe(8)
const s20 = evaluateDeathSentryStats(20, { fireBlast: 20 })
expect(s20.shots).toBe(11) // 5 + floor(20 / 3) = 5 + 6 = 11 shots
})
it('verifies Corpse Explosion radius progression: 10 + (slvl - 1) subtiles', () => {
const s1 = evaluateDeathSentryStats(1)
expect(s1.corpseExplosionRadiusSubtiles).toBe(10) // 10 subtiles = 3.3 yards
const s10 = evaluateDeathSentryStats(10)
expect(s10.corpseExplosionRadiusSubtiles).toBe(19) // 19 subtiles = 6.3 yards
const s20 = evaluateDeathSentryStats(20)
expect(s20.corpseExplosionRadiusSubtiles).toBe(29) // 29 subtiles = 9.6 yards
})
})
describe('4. Blade Skills Weapon Damage Scaling (srcDam 128ths)', () => {
it('scales Blade Sentinel weapon damage to exact 3/8 (37.5%)', () => {
expect(BLADE_SRC_DAM.BLADE_SENTINEL).toBe(48)
// 48 / 128 = 3/8
// Weapon damage 80 -> 80 * 3/8 = 30
expect(computeBladeWeaponDamage(80, ASSASSIN_TRAP_SKILL_IDS.BLADE_SENTINEL)).toBe(30)
// Weapon damage 240 -> 240 * 3/8 = 90
expect(computeBladeWeaponDamage(240, ASSASSIN_TRAP_SKILL_IDS.BLADE_SENTINEL)).toBe(90)
// Weapon damage 100 -> floor(100 * 48 / 128) = floor(37.5) = 37
expect(computeBladeWeaponDamage(100, ASSASSIN_TRAP_SKILL_IDS.BLADE_SENTINEL)).toBe(37)
})
it('scales Blade Fury weapon damage to exact 3/4 (75.0%)', () => {
expect(BLADE_SRC_DAM.BLADE_FURY).toBe(96)
// 96 / 128 = 3/4
// Weapon damage 80 -> 80 * 3/4 = 60
expect(computeBladeWeaponDamage(80, ASSASSIN_TRAP_SKILL_IDS.BLADE_FURY)).toBe(60)
// Weapon damage 240 -> 240 * 3/4 = 180
expect(computeBladeWeaponDamage(240, ASSASSIN_TRAP_SKILL_IDS.BLADE_FURY)).toBe(180)
// Weapon damage 100 -> floor(100 * 96 / 128) = 75
expect(computeBladeWeaponDamage(100, ASSASSIN_TRAP_SKILL_IDS.BLADE_FURY)).toBe(75)
})
it('scales Blade Shield weapon damage to exact 1/4 (25.0%)', () => {
expect(BLADE_SRC_DAM.BLADE_SHIELD).toBe(32)
// 32 / 128 = 1/4
// Weapon damage 80 -> 80 * 1/4 = 20
expect(computeBladeWeaponDamage(80, ASSASSIN_TRAP_SKILL_IDS.BLADE_SHIELD)).toBe(20)
// Weapon damage 240 -> 240 * 1/4 = 60
expect(computeBladeWeaponDamage(240, ASSASSIN_TRAP_SKILL_IDS.BLADE_SHIELD)).toBe(60)
// Weapon damage 100 -> floor(100 * 32 / 128) = 25
expect(computeBladeWeaponDamage(100, ASSASSIN_TRAP_SKILL_IDS.BLADE_SHIELD)).toBe(25)
})
it('combines flat physical damage with scaled weapon damage on Blade skills', () => {
const sentStats = evaluateBladeSentinelStats(20, 200)
// flat min = 79, max = 83. scaled weapon (200 * 3/8) = 75
expect(sentStats.scaledWeaponPhys).toBe(75)
expect(sentStats.totalMinPhys).toBe(79 + 75)
expect(sentStats.totalMaxPhys).toBe(83 + 75)
const furyStats = evaluateBladeFuryStats(20, 200)
// flat min = 101, max = 103. scaled weapon (200 * 3/4) = 150
expect(furyStats.scaledWeaponPhys).toBe(150)
expect(furyStats.totalMinPhys).toBe(101 + 150)
expect(furyStats.totalMaxPhys).toBe(103 + 150)
const shieldStats = evaluateBladeShieldStats(20, 200)
// flat min = 112, max = 141. scaled weapon (200 * 1/4) = 50
expect(shieldStats.scaledWeaponPhys).toBe(50)
expect(shieldStats.totalMinPhys).toBe(112 + 50)
expect(shieldStats.totalMaxPhys).toBe(141 + 50)
})
})
describe('5. Shock Web Cluster & Synergy Mechanics', () => {
it('calculates exact missile count: 6 + floor(slvl / 4) + floor(FireBlast.blvl / 3)', () => {
const sw1 = evaluateShockWebStats(1, { fireBlast: 0 })
expect(sw1.missileCount).toBe(6)
const sw4 = evaluateShockWebStats(4, { fireBlast: 0 })
expect(sw4.missileCount).toBe(7)
const sw20 = evaluateShockWebStats(20, { fireBlast: 0 })
expect(sw20.missileCount).toBe(11) // 6 + 5 = 11
const sw20_syn = evaluateShockWebStats(20, { fireBlast: 20 })
expect(sw20_syn.missileCount).toBe(17) // 6 + 5 + 6 = 17
})
it('generates distributed cluster points around impact origin', () => {
const points = computeShockWebClusterPoints(200, 200, 20, 20, 64)
expect(points).toHaveLength(17)
// Ensure all points lie within max cluster radius (64 + epsilon)
for (const pt of points) {
const dist = Math.hypot(pt.x - 200, pt.y - 200)
expect(dist).toBeLessThanOrEqual(65)
}
})
it('applies +11% synergy per hard point of CBS, LS, DS', () => {
const base = evaluateShockWebStats(20, { chargedBoltSentry: 0, lightningSentry: 0, deathSentry: 0 })
const syn = evaluateShockWebStats(20, { chargedBoltSentry: 20, lightningSentry: 20, deathSentry: 20 })
// 60 hard points * 11% = 660% bonus damage
expect(syn.synergyPct).toBe(660)
expect(syn.maxDamage).toBe(Math.floor(base.maxDamage * (1 + 6.6)))
})
})
describe('6. Sentry Lifecycles & Synergies', () => {
it('evaluates Charged Bolt Sentry shots and bolt count scaling', () => {
const cbs = evaluateChargedBoltSentryStats(20, {
lightningSentry: 20, // +5 shots
shockWeb: 20, // +6 bolts
})
// Base shots: 5 + floor(20/4) = 10 shots
expect(cbs.shots).toBe(10)
// Base bolts: 5 + floor(20/3) = 11 bolts per shot
expect(cbs.boltsPerShot).toBe(11)
})
it('evaluates Lightning Sentry piercing damage and 12% synergy scaling', () => {
const lsBase = evaluateLightningSentryStats(20, { shockWeb: 0, chargedBoltSentry: 0, deathSentry: 0 })
expect(lsBase.shots).toBe(10)
expect(lsBase.minDamage).toBe(1)
expect(lsBase.maxDamage).toBe(314) // 5-band level 20 max
const lsMaxSyn = evaluateLightningSentryStats(20, { shockWeb: 20, chargedBoltSentry: 20, deathSentry: 20 })
// 60 * 12% = 720% bonus
expect(lsMaxSyn.synergyPct).toBe(720)
expect(lsMaxSyn.maxDamage).toBe(Math.floor(314 * (1 + 7.2)))
})
it('evaluates Wake of Inferno 10 attacks and 7% synergies', () => {
const woi = evaluateWakeOfInfernoStats(20, { fireBlast: 20, wakeOfFire: 20, deathSentry: 20 })
expect(woi.attacks).toBe(10)
// 60 * 7% = 420% synergy
expect(woi.synergyPct).toBe(420)
})
it('evaluates Blade Shield 20s-115s duration and 25-frame tick rate', () => {
const bs1 = evaluateBladeShieldStats(1)
expect(bs1.durationFrames).toBe(500) // 20s
expect(bs1.tickRateFrames).toBe(25) // 1.0s
expect(bs1.radiusSubtiles).toBe(6)
const bs20 = evaluateBladeShieldStats(20)
expect(bs20.durationFrames).toBe(2875) // 500 + 125 * 19 = 2875 frames = 115s
expect(bs20.tickRateFrames).toBe(25)
expect(bs20.manaCost).toBe(65) // 27 + 2 * 19 = 65
})
})
})