Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a635d07b96
|
||
|
|
7489435a1b
|
||
|
|
7fe5ab669b | ||
|
|
3ce1e71695 | ||
|
|
8ab335d383 | ||
|
|
59e006c394 | ||
|
|
e69567c520 | ||
|
|
9618ab0c1d | ||
|
|
d7623cc0d7 | ||
|
|
8055ed5502 | ||
|
|
a5554fd8aa | ||
|
|
cd86fbf66c | ||
|
|
8db10c3e5e | ||
|
|
fff3ca84e9 | ||
|
|
18af8fa1bf | ||
|
|
3dddab3e50 | ||
|
|
e1e6bbe448 | ||
|
|
c56995f8c2 | ||
|
|
7ae541c9cc | ||
|
|
ffd9d33a52 | ||
|
|
5802034fa5 | ||
|
|
49dc96ab2f | ||
|
|
b82292478a | ||
|
|
144c3f5a05 | ||
|
|
adc8380879 | ||
|
|
4ab16cff40 | ||
|
|
b8bcb88758 | ||
|
|
94549fec46 | ||
|
|
6f33807366 | ||
|
|
b3a6039918 | ||
|
|
0484ead128 | ||
|
|
e20fe988e9 | ||
|
|
2c68c94b7e | ||
|
|
8c9d6e8f3d | ||
|
|
a80f9c2872 | ||
|
|
9cdfd111eb | ||
|
|
869f03805e | ||
|
|
eb905f21b8 | ||
|
|
20249db1ee | ||
|
|
f32671bc02 | ||
|
|
729c311e6e | ||
|
|
b0f451d7de | ||
|
|
1eb1d199e2 | ||
|
|
f922027474 | ||
|
|
0ac2dbb09b | ||
|
|
0eb1a9232d | ||
|
|
e335cc60ef | ||
|
|
224e42c868 | ||
|
|
7918b92376 | ||
|
|
8a7c22a988 | ||
|
|
cebcead148 | ||
|
|
0c4f78b23b | ||
|
|
8e9ee90cec | ||
|
|
7d3b41707b | ||
|
|
53780bc3b2 | ||
|
|
bc4134bd99 | ||
|
|
f6a5c7ae3d | ||
|
|
4012f50242 | ||
|
|
140d6a5271 | ||
|
|
c6e14bac3d | ||
|
|
e3d9469071 | ||
|
|
c9c26f4d32 | ||
|
|
65e28fa61f | ||
|
|
a82b5fa5c2 | ||
|
|
514abf2080 | ||
|
|
ae1b8575bb | ||
|
|
57de413447 | ||
|
|
6782c6b37d | ||
|
|
0cca53d0bf | ||
|
|
3bf236222f | ||
|
|
cae26e6b7f | ||
|
|
8c71baf9fe | ||
|
|
5c252535fe | ||
|
|
2e1ef440a9 | ||
|
|
fb65c6f65e | ||
|
|
b0fdae3a08 | ||
|
|
027a5a74dc | ||
|
|
0837305721 | ||
|
|
f9017651b4 | ||
|
|
8f14b8b8e2 | ||
|
|
8fd72f815a | ||
|
|
b5341f3898 | ||
|
|
bfbb2e420f | ||
|
|
b12829d7c3 | ||
|
|
67e266c938 | ||
|
|
b89b07ac98 | ||
|
|
4ace28e368 | ||
|
|
0dea6da0f7 | ||
|
|
59a5750cd6 | ||
|
|
096a35f86d | ||
|
|
cbb6f7fecf | ||
|
|
d30a4a9d7b | ||
|
|
4f4f8b890a | ||
|
|
1bb6776ed9 | ||
|
|
dfaaefa579 | ||
|
|
7f040dca33 | ||
|
|
f3372b11e7 | ||
|
|
00f0efe865 | ||
|
|
aba5d1d2f3 | ||
|
|
8e18ac385b | ||
|
|
9d09401374 | ||
|
|
b84e41ddd0 | ||
|
|
b4b791150f | ||
|
|
fff3580cf6 | ||
|
|
fd3fca9b11 | ||
|
|
125a902bc3 | ||
|
|
5d74faa534 | ||
|
|
0d21867838 | ||
|
|
4993f7e9ac | ||
|
|
a25e0ed24c | ||
|
|
3c3bc1e26c | ||
|
|
89dfc22092 | ||
|
|
83a7154d8c | ||
|
|
7627b24863 | ||
|
|
c228809ac9 | ||
|
|
83c08b5ff5 | ||
|
|
bd88c2bc93 | ||
|
|
d4eee15c54 | ||
|
|
7edb0a525d | ||
|
|
af45cff7b7 | ||
|
|
890c41f442 | ||
|
|
a091b06889 | ||
|
|
c2043e23e0 | ||
|
|
081741cf36 | ||
|
|
b194693853 | ||
|
|
561b9fc6cf | ||
|
|
e1e5532817 | ||
|
|
b4bcaac897 | ||
|
|
44fe7585a1 | ||
|
|
5d3887d5b1 | ||
|
|
ad05bd2916 | ||
|
|
f3833406e4 | ||
|
|
7817d9c1da | ||
|
|
cc0c2a51df | ||
|
|
f0a71eeed7 | ||
|
|
2bb730e5c2 | ||
|
|
96d698145e | ||
|
|
a92664d293 | ||
|
|
8889bfeb46 | ||
|
|
727fa97b4e | ||
|
|
cfc596c3d6 | ||
|
|
68073e876d | ||
|
|
a2609191a6 | ||
|
|
15d56e20b0 | ||
|
|
388bee5731 | ||
|
|
b887a28a95 | ||
|
|
4624ac0c42 | ||
|
|
432385320c | ||
|
|
afdf309265 | ||
|
|
f484f12b5f | ||
|
|
3b095d70ff | ||
|
|
8fd5a10bd4 | ||
|
|
10ff4ceead | ||
|
|
e01faea183 | ||
|
|
3198f1899e | ||
|
|
5899937aaf | ||
|
|
44afce1a0f | ||
|
|
ebdfd101ae | ||
|
|
0ca855a216 | ||
|
|
4be1c20fb0 | ||
|
|
83ff726c5a | ||
|
|
c9563b0578 | ||
|
|
4f8df3c5d0 | ||
|
|
1fc05721bf | ||
|
|
c7932d6f0b | ||
|
|
a18b1efec0 | ||
|
|
9667ef539e | ||
|
|
b605501b40 | ||
|
|
e8f28b366c | ||
|
|
5856bed518 | ||
|
|
d30a6ff244 | ||
|
|
a08b5b47bc | ||
|
|
2825b35b4c | ||
|
|
8596c9da58 | ||
|
|
37528722dd | ||
|
|
d3dcf27bc2 | ||
|
|
c85914f562 | ||
|
|
f8d5648493 | ||
|
|
d2f6af98f6 | ||
|
|
f763d380a4 | ||
|
|
90e61197b0 | ||
|
|
131fbc0f9b | ||
|
|
5138f63526 | ||
|
|
d0c3d2ce82 | ||
|
|
9ea7641a12 | ||
|
|
143f451205 | ||
|
|
481c30eb56 | ||
|
|
5cbd93d89d | ||
|
|
a623bce5e1 | ||
|
|
be70c5bb77 | ||
|
|
cbef70791e | ||
|
|
bba1ecb62e | ||
|
|
207f80230e | ||
|
|
cfa1e58b5b | ||
|
|
bb9afdea2d | ||
|
|
1408720089 | ||
|
|
f96eae293c | ||
|
|
2f23db64be | ||
|
|
8bcb3956f4 | ||
|
|
91b121ff00 | ||
|
|
54c432f5d7 | ||
|
|
4cecb649c8 | ||
|
|
5e13a5fb7b | ||
|
|
11ec51bd60 | ||
|
|
2339c2e59c | ||
|
|
7a0171340c | ||
|
|
a656644bdb | ||
|
|
9af8a0fa79 | ||
|
|
ee1081ad00 | ||
|
|
1256c918a3 | ||
|
|
3dc8aa9750 | ||
|
|
5c7ef8a27f | ||
|
|
cf96d8b9a8 | ||
|
|
1321717288 | ||
|
|
5f7bf12741 | ||
|
|
18dd6e17ae | ||
|
|
ee71088e2f | ||
|
|
07159623f9 | ||
|
|
54aaf0c1a0 | ||
|
|
ecca0b56d6 | ||
|
|
161bbc14db | ||
|
|
0b25fe7af9 | ||
|
|
d139e09831 | ||
|
|
5014c805b9 | ||
|
|
0692dbbed6 | ||
|
|
0e979bc56c | ||
|
|
e9039523a5 | ||
|
|
c547cc2c7d | ||
|
|
5ddf03171d | ||
|
|
1e4a59ee02 | ||
|
|
e361578ff6 | ||
|
|
06753f1b71 | ||
|
|
b8dcf4282e | ||
|
|
ab5eacb745 | ||
|
|
d1c905d7af | ||
|
|
d86601504e | ||
|
|
f502adbbec | ||
|
|
66b465c092 | ||
|
|
16d4eeabff | ||
|
|
d5053b1742 | ||
|
|
c20937a8ad | ||
|
|
a46d54b41a | ||
|
|
977f118475 | ||
|
|
8023c3d574 | ||
|
|
88f04efe67 | ||
|
|
ac0bfcebf5 | ||
|
|
64eb5b1baf | ||
|
|
cdebc3b7ce | ||
|
|
a4db0b071e | ||
|
|
ec4ba22d97 | ||
|
|
942672a54d | ||
|
|
fa828d3469 | ||
|
|
945313618a | ||
|
|
09effedf13 | ||
|
|
47ffa963ec | ||
|
|
b8f0d2900f | ||
|
|
3e4b6ce9e1 | ||
|
|
edbfef4e66 | ||
|
|
6e7505f9ef | ||
|
|
63447dc044 | ||
|
|
46d88581d8 | ||
|
|
10183dea2f | ||
|
|
d1527d3dc0 | ||
|
|
c63c940fcc | ||
|
|
b6ad7d1c48 | ||
|
|
3c3d0205ce | ||
|
|
11e787b000 | ||
|
|
c2749dec17 | ||
|
|
1592dc2498 | ||
|
|
5927729ea5 | ||
|
|
fc51924504 | ||
|
|
09fd3c556a | ||
|
|
e2f0369f29 | ||
|
|
8d88a49d0f | ||
|
|
462e536628 | ||
|
|
4673b43b1e | ||
|
|
f0abdd382c | ||
|
|
a6ea7a26b5 | ||
|
|
9291ccbcaf | ||
|
|
af1d1931f7 | ||
|
|
015dd30848 | ||
|
|
d23a14459e | ||
|
|
573b769b99 | ||
|
|
dab0b4f978 | ||
|
|
e969e18e10 | ||
|
|
43aedbbe3a | ||
|
|
1cac49a2f0 | ||
|
|
8fc34f9805 | ||
|
|
00b740f4e5 | ||
|
|
96df667470 | ||
|
|
44e47b7489 | ||
|
|
32f3b48c60 | ||
|
|
3c01ad336e | ||
|
|
eeff170c7a | ||
|
|
e72837462f | ||
|
|
68c51ab880 | ||
|
|
d119c9f35e | ||
|
|
31e3877330 | ||
|
|
b355322f9b | ||
|
|
4ef28fb7bd | ||
|
|
b2379cc17e | ||
|
|
b30e27ddb3 | ||
|
|
95c9446edb | ||
|
|
aa6d908af6 | ||
|
|
7d78a9b0d0 | ||
|
|
da3d5f0b28 | ||
|
|
c7ebb094bb | ||
|
|
35fe52505b | ||
|
|
468a126066 | ||
|
|
f75448675e | ||
|
|
bf3077be04 | ||
|
|
cd280ac7a4 | ||
|
|
00edc7963d | ||
|
|
f5b59d7537 | ||
|
|
822fb1fa14 | ||
|
|
b299df2d21 | ||
|
|
8aa3fa3308 | ||
|
|
cbc0c4bc8b | ||
|
|
07106e61a2 | ||
|
|
556543f516 | ||
|
|
bd38b9274e | ||
|
|
684e481e8d | ||
|
|
c32540f1ce | ||
|
|
04e76fe6c3 | ||
|
|
9d80765bb9 | ||
|
|
90a1506ff7 | ||
|
|
f05edcb096 | ||
|
|
e1f3329c0a | ||
|
|
56dd66a4bf | ||
|
|
1540902c6b | ||
|
|
f23bbe4fd1 | ||
|
|
59ef76fb9d | ||
|
|
5d98ed36d6 | ||
|
|
7de3ecd4b5 | ||
|
|
b92e7649a5 | ||
|
|
534f5cd2ee | ||
|
|
5780737e6b | ||
|
|
c6cecf3b29 | ||
|
|
e3260026ee | ||
|
|
d462156e39 | ||
|
|
a5baba0faf | ||
|
|
cb3ece93c7 | ||
|
|
aa83c729cd | ||
|
|
5dd99473b7 | ||
|
|
317b58c360 | ||
|
|
fd92debc10 | ||
|
|
ddcc79088b | ||
|
|
4c883866be | ||
|
|
379523b0f9 | ||
|
|
4c8a09872a | ||
|
|
8e39242c70 | ||
|
|
3b04796c8a | ||
|
|
09444594ae | ||
|
|
61c3155d1c | ||
|
|
42fdee7721 | ||
|
|
869f4237fd | ||
|
|
2608fdaef3 | ||
|
|
f9c538f2ce | ||
|
|
c93a6af16e | ||
|
|
954cc3204c | ||
|
|
431198e64b | ||
|
|
300efd61da | ||
|
|
221c619aa3 | ||
|
|
7b2142a3b1 | ||
|
|
526b819a1a | ||
|
|
222165df74 | ||
|
|
b55168d76d | ||
|
|
8bf94b8d6c | ||
|
|
1fc6c8cde2 | ||
|
|
f744ff1f7d | ||
|
|
a1f7ba7cce | ||
|
|
fd9df6379e | ||
|
|
204980a6df | ||
|
|
7f15383a06 | ||
|
|
b385f487b4 | ||
|
|
291c3560fa | ||
|
|
bda02c73c9 | ||
|
|
1400051d31 | ||
|
|
45ec9fa4b9 | ||
|
|
aea9c63869 | ||
|
|
41ad033df4 | ||
|
|
68941fc238 | ||
|
|
76fbcc96c1 | ||
|
|
649f50ba92 | ||
|
|
ea563c1f7e | ||
|
|
74af8762c6 | ||
|
|
dab1e024aa | ||
|
|
a196e6ec93 | ||
|
|
e6b94b14ae | ||
|
|
8dc8986320 | ||
|
|
3b9d50bd4c | ||
|
|
b47a26cf3f | ||
|
|
8fd0e0fa02 | ||
|
|
976d31fa03 | ||
|
|
11ee79af73 | ||
|
|
3358fcea6d | ||
|
|
e732739308 | ||
|
|
226326875c | ||
|
|
8d99b60cac | ||
|
|
c3c4d4b207 | ||
|
|
a24d6c14ff | ||
|
|
fdc1073f26 | ||
|
|
fab515b076 | ||
|
|
ffffaaf564 | ||
|
|
f3877968c3 | ||
|
|
431ca863e7 | ||
|
|
d310c4b9ec | ||
|
|
2071fb0d75 | ||
|
|
a01a746555 | ||
|
|
573a20079a | ||
|
|
bbd4add1c3 | ||
|
|
e30298b97a | ||
|
|
8bb5d8624b | ||
|
|
625e722d46 | ||
|
|
6c82990a05 | ||
|
|
924dc7b4de | ||
|
|
ecfc6d5801 | ||
|
|
39ca1c0c0e | ||
|
|
fd5a53b464 | ||
|
|
7dc4ef8865 | ||
|
|
c13385dcd0 | ||
|
|
8f85735461 | ||
|
|
e22877747f | ||
|
|
104003bc8e | ||
|
|
049fd660fc | ||
|
|
2ff61d8630 | ||
|
|
a03cc705b6 | ||
|
|
77a1d4ceec | ||
|
|
09325911ae | ||
|
|
3604c58268 | ||
|
|
b466adffdd | ||
|
|
bc6ce2f5a3 | ||
|
|
120bf2dca2 | ||
|
|
1e74bad0c9 | ||
|
|
b447df664c | ||
|
|
c1c3b3514a | ||
|
|
a529d78a5c | ||
|
|
7484296c50 | ||
|
|
041cff8f93 | ||
|
|
302df1b568 | ||
|
|
a87bbd200c | ||
|
|
bb705851d4 | ||
|
|
f23a8563b3 | ||
|
|
5123d152ba | ||
|
|
de9e1af80e | ||
|
|
fc5e880553 | ||
|
|
96e7d49e85 | ||
|
|
0ab59af26b | ||
|
|
ce8b59c762 | ||
|
|
5a83b0d8d7 | ||
|
|
06e5860d1a | ||
|
|
dedd7089bd | ||
|
|
ab69cf415f | ||
|
|
13b3147ee8 | ||
|
|
455fa74f9b | ||
|
|
db2436ff50 | ||
|
|
bf46ce8008 | ||
|
|
bfc2c30750 | ||
|
|
ec4dea72bc | ||
|
|
f94b8d7835 | ||
|
|
1edfa9dce8 | ||
|
|
25375793c8 | ||
|
|
8ad586bb7c | ||
|
|
99f412bcfd | ||
|
|
7ea1e01361 | ||
|
|
c9de1b4cd1 | ||
|
|
3f6271e168 | ||
|
|
dedb55ba4f | ||
|
|
c66be130bd | ||
|
|
55723c6d58 | ||
|
|
330c7b6606 | ||
|
|
bfd20acf80 | ||
|
|
093c584cdd | ||
|
|
d892cca004 | ||
|
|
fbcd6457ce | ||
|
|
4b241c93c9 | ||
|
|
542985f5fb | ||
|
|
1daaf60111 | ||
|
|
4be7ab2f7a | ||
|
|
6002fa5088 | ||
|
|
0e8dd4adc1 | ||
|
|
3f8c3a59e9 | ||
|
|
23395fd643 | ||
|
|
b929c59ce1 | ||
|
|
cf7a0ceddd | ||
|
|
c3af608d8a | ||
|
|
d69cf819f2 | ||
|
|
681bb79acf | ||
|
|
49a34de440 | ||
|
|
1a27589565 | ||
|
|
86ca899504 | ||
|
|
b0e7ca1a70 | ||
|
|
86fabe11e1 | ||
|
|
6c15ed866e | ||
|
|
9c15014a9a | ||
|
|
f1a145126a | ||
|
|
c0d847658e | ||
|
|
9e3dfe101a | ||
|
|
112cf2d038 | ||
|
|
03f6c26aae | ||
|
|
8e3d9524eb | ||
|
|
b81ee55cd3 | ||
|
|
5865bd7948 | ||
|
|
10520d87d6 | ||
|
|
89c853241e | ||
|
|
39f01f3540 | ||
|
|
1b654cc4e8 | ||
|
|
418d4cf04b | ||
|
|
5c45165dcd | ||
|
|
925daec1a9 | ||
|
|
033723712b | ||
|
|
64ce843f82 | ||
|
|
8dc49347b7 | ||
|
|
20d63efdce | ||
|
|
ee93517877 | ||
|
|
a9014b4592 | ||
|
|
a8cd61fee5 | ||
|
|
5f1a8d73b0 | ||
|
|
f7de27d1e5 | ||
|
|
a271817375 | ||
|
|
3880ef041a | ||
|
|
cd362f5df6 | ||
|
|
f10fe7e414 | ||
|
|
3fbd4d583e | ||
|
|
c257cc280d | ||
|
|
ae2b408dc6 | ||
|
|
43c09b227b | ||
|
|
a3e5b841b3 | ||
|
|
50545ff70a | ||
|
|
5aab53d96c | ||
|
|
7f6e1aa568 | ||
|
|
7926abe57a | ||
|
|
eb5e8bb087 | ||
|
|
5a1d9d976d | ||
|
|
d667bcdbf1 | ||
|
|
e18db6d87e | ||
|
|
224592363a | ||
|
|
007806f56c | ||
|
|
7c81b677a0 | ||
|
|
6014c3dc2b | ||
|
|
7b5c58740a | ||
|
|
43d9a31758 | ||
|
|
8e768c1d74 | ||
|
|
e3f2c4740a | ||
|
|
1f3f3e9c9b | ||
|
|
d5698ff607 | ||
|
|
e9c5efff83 | ||
|
|
3965aacc14 | ||
|
|
8dce9742a7 | ||
|
|
d07db195ac | ||
|
|
bbe3b9a25d | ||
|
|
e4eb6a3b06 | ||
|
|
780119189c | ||
|
|
489cb835a0 | ||
|
|
02fbd0646d | ||
|
|
cbe2f18112 | ||
|
|
e68700f919 | ||
|
|
fa046f1ac6 | ||
|
|
fca1ca5eec | ||
|
|
31b73104bf | ||
|
|
49424bf8b3 | ||
|
|
595ef3bb62 | ||
|
|
54422831bb | ||
|
|
ae4a9927c6 | ||
|
|
445e8ddc95 | ||
|
|
f499884219 | ||
|
|
b1bc030794 | ||
|
|
30887bc99b | ||
|
|
335abd0ffd | ||
|
|
5c74fe3413 | ||
|
|
d10a704609 | ||
|
|
6030f4ccad | ||
|
|
eb59be00cb | ||
|
|
e75a6db729 | ||
|
|
ab628c6636 | ||
|
|
edd7ed153c | ||
|
|
6a0c272256 | ||
|
|
a88f9d671a | ||
|
|
1661ad7e50 | ||
|
|
1b0d81ccf8 | ||
|
|
0a0909bd56 | ||
|
|
6fda20ad68 | ||
|
|
9dcc9e2034 | ||
|
|
e808a8db14 | ||
|
|
1652ab8bca | ||
|
|
4e43c0002a | ||
|
|
7f39274b81 | ||
|
|
1df299b3d6 | ||
|
|
10adca1127 | ||
|
|
51398c1c33 | ||
|
|
caf99eb59e | ||
|
|
9e127e671c | ||
|
|
89fbf3861f | ||
|
|
ad6d51aa50 | ||
|
|
9bdf72fef2 | ||
|
|
ecfcdee26d | ||
|
|
528436c366 | ||
|
|
bda5a03418 | ||
|
|
51692d3d77 | ||
|
|
d704fced4d | ||
|
|
a63503a8f7 | ||
|
|
ee26c07d89 | ||
|
|
dbeae5b7e4 | ||
|
|
03c5d2a53b | ||
|
|
1bde80648a | ||
|
|
2cf0b78364 | ||
|
|
36c64d2bf2 | ||
|
|
5aab9d31ca | ||
|
|
d222286fd8 | ||
|
|
4f7570755a | ||
|
|
1c88fc9a7e | ||
|
|
999e134891 | ||
|
|
c28e9557b4 | ||
|
|
6495396dd3 | ||
|
|
0bf90ff61a | ||
|
|
d0a3755831 | ||
|
|
c5712dd226 | ||
|
|
cf60e4eee8 | ||
|
|
fe6f58eb32 | ||
|
|
cefbd0db2c | ||
|
|
12cb6c3809 | ||
|
|
e9ba161b89 | ||
|
|
5bb042b09f | ||
|
|
d0018dd581 | ||
|
|
5424472318 | ||
|
|
e17e43570d | ||
|
|
f5d4821be6 | ||
|
|
82b5f32d46 | ||
|
|
6fd52189db | ||
|
|
f568baaf03 | ||
|
|
a50cb79bda | ||
|
|
c2aad88d71 | ||
|
|
ec0bd8217b | ||
|
|
2ffb01b531 | ||
|
|
b177662e38 | ||
|
|
dcd9ed1e52 | ||
|
|
9753fe7731 | ||
|
|
a88b6d22cd | ||
|
|
84d06627e2 | ||
|
|
fcebce2726 | ||
|
|
a4cb7f7bd3 | ||
|
|
59fc09cb11 | ||
|
|
cedac9883c | ||
|
|
45b1f4aab7 | ||
|
|
2f4c389f54 | ||
|
|
a643af60b9 | ||
|
|
99c418c80e | ||
|
|
4cf9bfd720 | ||
|
|
6f0c7907ad | ||
|
|
0f32587abd | ||
|
|
0bfb6614b7 | ||
|
|
e4e99b419c | ||
|
|
dbfbdb1967 | ||
|
|
c7a0d1e402 | ||
|
|
775dda92f5 | ||
|
|
90074e766a | ||
|
|
cd48e2972d | ||
|
|
7f1450c73f | ||
|
|
3d01f0f192 | ||
|
|
35ed292526 | ||
|
|
1ab60a0f2c | ||
|
|
92abec803d | ||
|
|
3025f7416a | ||
|
|
aea1130325 | ||
|
|
faebd51d54 | ||
|
|
0efd9dc8af | ||
|
|
6a002c1e20 | ||
|
|
c444fbd692 | ||
|
|
5053eb8419 | ||
|
|
e30182e803 | ||
|
|
5692df08e2 | ||
|
|
e4dcad5361 | ||
|
|
6b50113062 | ||
|
|
2e13adaaae | ||
|
|
c0ccc4eaad | ||
|
|
b5ba9479ab | ||
|
|
cdc5c89850 | ||
|
|
671e0cfd34 | ||
|
|
6b98257511 | ||
|
|
4e05ad6b6a | ||
|
|
dadb19d75a | ||
|
|
b73d4e4997 | ||
|
|
e97b50a064 | ||
|
|
3857cb0938 | ||
|
|
602cfb01c7 | ||
|
|
2abab21008 | ||
|
|
45ff0dd185 | ||
|
|
46bac42a90 | ||
|
|
05fef9c84a | ||
|
|
8209ae80ae | ||
|
|
e8408ec819 | ||
|
|
4c21d69237 | ||
|
|
4b86ee9b7a | ||
|
|
d3df10995f | ||
|
|
aaea7c7391 | ||
|
|
12225f5427 | ||
|
|
5fb06c743a | ||
|
|
d944b2a157 | ||
|
|
f2c4a16e2d | ||
|
|
adeabf1032 | ||
|
|
e6f964d449 | ||
|
|
f1691b1351 | ||
|
|
c9a13443d5 | ||
|
|
cf7fe6975f | ||
|
|
7eb2747426 | ||
|
|
c6d0b67033 | ||
|
|
10a790a3cf | ||
|
|
447a8dc984 | ||
|
|
5c7d70a8b2 | ||
|
|
b4329534b2 | ||
|
|
b79e216913 | ||
|
|
c542525284 | ||
|
|
59c0910ec6 | ||
|
|
6a1e15ae1d | ||
|
|
14d47b707a | ||
|
|
825c089635 | ||
|
|
8b2ff5f631 | ||
|
|
05382e15a9 | ||
|
|
84e4fb67fc | ||
|
|
59ecdc25d9 | ||
|
|
d27efbdb53 | ||
|
|
4df12fd702 | ||
|
|
e1e586ad78 | ||
|
|
7df798ff47 | ||
|
|
d6719cf5b2 | ||
|
|
b19225715b | ||
|
|
892b0d793f | ||
|
|
e34a26f066 | ||
|
|
ff7be828f5 | ||
|
|
b83f094a8d | ||
|
|
ffe311e60e | ||
|
|
0dacfd5282 | ||
|
|
2980111ec5 | ||
|
|
b80625d80b | ||
|
|
f8f5b694bc | ||
|
|
234c6cfbdf | ||
|
|
fed8b2ac73 | ||
|
|
04cc5f49ee | ||
|
|
21e5597590 | ||
|
|
d674a8ad55 | ||
|
|
9c5bcddd9d | ||
|
|
bf83194a74 | ||
|
|
46ba2289f4 | ||
|
|
514784db82 | ||
|
|
593853141e | ||
|
|
2881685c67 | ||
|
|
e2bc0cf541 | ||
|
|
eabde2e73d | ||
|
|
f6ae170053 | ||
|
|
8da7834336 | ||
|
|
5d0cc01ce7 | ||
|
|
c51d1caa41 |
No files matched your search
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: commit-preparation
|
||||
description: Supports preparing commits, pre-commit hooks, Spotless, formatting or linting, validation before commit, staging files, and writing conventional commits.
|
||||
---
|
||||
|
||||
# Commit Preparation
|
||||
|
||||
Use this skill whenever an agent is preparing a repository state for commit, validating changes before commit, handling pre-commit hooks, or choosing staging and commit-message details.
|
||||
|
||||
## Required checks before committing
|
||||
|
||||
- Inspect `git status`, `git diff`, and recent commits before committing.
|
||||
- Run `./gradlew spotlessCheck` before committing.
|
||||
- Run `./gradlew check` before committing, unless a narrower validation is clearly justified for the change and that justification is reported in chat.
|
||||
- Run relevant integration tests for behaviour, compatibility, module, or bug-fix changes.
|
||||
- Use `integration-test-verification` to choose an integration-test matrix and to triage integration-test failures.
|
||||
- Never skip, weaken, disable, or version-gate tests to make validation pass. Fix the root cause or report the failure as a blocker unless the user explicitly approves a coverage reduction.
|
||||
|
||||
## Staging and commit messages
|
||||
|
||||
- Stage only intended files, and re-check the staged diff before committing.
|
||||
- Use conventional commits.
|
||||
- Keep commit messages subject-only unless the user explicitly asks for a body.
|
||||
- Make the subject release-note-friendly and comprehensible without the type prefix or body.
|
||||
- For issue, ticket, or bug-report driven work, include the ticket number in the commit subject, such as `fix(modesets): correct stale stored world modesets (#865)`.
|
||||
|
||||
## Reporting
|
||||
|
||||
- Keep validation findings in chat unless committing is explicitly requested.
|
||||
- If validation is narrowed, skipped, blocked, or failing, report the reason and any remaining risk before committing.
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: compatibility-strategy
|
||||
description: Use for Java 8 backports, Bukkit/Paper version differences, NMS/reflection, PacketEvents compatibility, fake-player implementation choices, and feature-detection design; do not use for release publishing, PR wording, or simple test-running decisions.
|
||||
---
|
||||
|
||||
# Compatibility Strategy
|
||||
|
||||
Use this skill when a change must behave across legacy and modern Bukkit/Paper versions or across different Java runtimes.
|
||||
|
||||
## When to use
|
||||
|
||||
- Java 8 source compatibility or dependency updates that might introduce newer APIs.
|
||||
- NMS, CraftBukkit, Paper, PacketEvents, XSeries, or reflection-sensitive changes.
|
||||
- Fake-player internals, attack delivery, fire ticks, entity ticking, or synthetic network plumbing.
|
||||
- Decisions about feature detection versus hard-coded Minecraft version checks.
|
||||
- Fallback behaviour on Spigot, Paper, legacy 1.12/1.9, or modern 1.20.5+ servers.
|
||||
|
||||
## When not to use
|
||||
|
||||
- Do not use for release asset or publishing checks; use `release-readiness-review`.
|
||||
- Do not use for config/modeset-only changes unless compatibility affects the implementation; use `module-config-change` first.
|
||||
- Do not use for PR prose only; use `pr-draft-summary`.
|
||||
|
||||
## Required stance
|
||||
|
||||
- Prefer direct supported APIs when present.
|
||||
- Use reflection only as a fallback or compatibility bridge, especially off hot paths.
|
||||
- Prefer feature detection by class, method, field, component, or capability over hard-coded version numbers.
|
||||
- Keep Java 8-targeted code free of Java 9+ collection factories, `Stream.toList()`, records, pattern matching, and other newer language/runtime features.
|
||||
- For NMS access, prefer `utilities.reflection.Reflector` helpers where they fit the problem.
|
||||
- Avoid hard-coded versioned NMS class names unless no heuristic or API-based path is viable.
|
||||
|
||||
## Compatibility checklist
|
||||
|
||||
1. Identify the minimum supported Java runtime for the touched source set.
|
||||
2. Identify whether Bukkit/Paper exposes a direct API for the target behaviour.
|
||||
3. If an API may be missing or backported, add runtime feature detection.
|
||||
4. Choose a safe fallback that preserves existing behaviour where feature detection fails.
|
||||
5. Keep diagnostics actionable when every candidate path fails.
|
||||
6. Select validation versions that cover the compatibility boundary, usually a legacy version plus a modern Paper version.
|
||||
|
||||
## Java 8 reminders
|
||||
|
||||
- Use `collect(Collectors.toList())` instead of `Stream.toList()`.
|
||||
- Use mutable collections wrapped with `Collections.unmodifiable*` where immutable Java 9 factories would otherwise be tempting.
|
||||
- Avoid records, sealed classes, switch expressions, pattern matching, and `var` in Java source.
|
||||
- Main build config targets Java 8 via `options.release.set(8)` and Kotlin `jvmTarget = 1.8`.
|
||||
|
||||
## Fake-player and NMS reminders
|
||||
|
||||
- The primary fake-player path is modern-NMS-oriented and relies on reflection remapping.
|
||||
- Legacy 1.12 uses versioned NMS and currently needs dedicated or version-aware handling for realistic fake-player tests.
|
||||
- Fake players are simulated login/network entities, not real network clients.
|
||||
- PacketEvents may need fake users or explicit client-version seeding in synthetic scenarios.
|
||||
|
||||
## References
|
||||
|
||||
- Broader test-harness and historical notes: `../integration-test-verification/references/relocated-agents-notes.md`.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: dependabot-pr-review
|
||||
description: Use for Dependabot PRs, dependency bumps, Gradle or Maven dependency updates, GitHub Actions updates, dependency changelog/licence/release-note review, JVM/classfile checks, and validation recommendations.
|
||||
---
|
||||
|
||||
# Dependabot PR Review
|
||||
|
||||
Use this skill when reviewing dependency or workflow version updates. Keep the review evidence-based, scoped to the changed dependency, and explicit about what must be checked before merge.
|
||||
|
||||
## Cross-load related skills
|
||||
|
||||
- Use `integration-test-verification` for integration-test matrix selection, compact failure triage, Kotest registration, or any full integration log hand-off.
|
||||
- Use `compatibility-strategy` for Java 8, Bukkit/Paper/Spigot, NMS/reflection, PacketEvents, fake-player, or API compatibility concerns.
|
||||
- Use `release-readiness-review` for licence, shaded dependency, release-note, publishing, or asset-impact questions.
|
||||
|
||||
## Review workflow
|
||||
|
||||
1. Identify the update manager, dependency scope, touched files, and whether the dependency is production, compile-only, test-only, integration-test-only, or GitHub Actions.
|
||||
2. Read the PR diff and the upstream release notes, changelog, migration guide, and security notes for every updated version range.
|
||||
3. Check licence changes and whether the dependency ships in the release jar, shaded jar, or integration-test jar.
|
||||
4. Check JVM bytecode, classfile version, toolchain, and Gradle module metadata against main Java 8 support and the integration-test Java bands.
|
||||
5. Check Bukkit/Paper/Spigot compatibility when the dependency is server-facing, API-adjacent, PacketEvents-adjacent, or affects plugin loading.
|
||||
6. Check shading, relocation, service files, transitive dependency, and duplicate class risks, especially for anything bundled into the plugin jar.
|
||||
7. For GitHub Actions bumps, check Node/runtime changes, runner image requirements, permission model changes, and deprecated input/output behaviour.
|
||||
8. Inspect CI status and rerun the narrowest relevant validation where appropriate; prefer static checks for documentation-only or metadata-only bumps.
|
||||
9. Root and user-facing agents must not open `build/integration-test-logs/*.log` directly. If compact summaries are insufficient, delegate full integration log inspection to a subagent.
|
||||
|
||||
## Validation expectations
|
||||
|
||||
- Do not skip, weaken, disable, or version-gate tests to make a dependency update pass.
|
||||
- Match validation to risk: build metadata can often use wrapper/config checks; runtime libraries may need unit or integration tests; server-facing changes may need selected Paper matrix runs.
|
||||
- Prefer checking the produced dependency graph or shaded jar contents when packaging impact is plausible.
|
||||
|
||||
## Verdicts
|
||||
|
||||
- `merge`: low-risk update, relevant notes reviewed, CI/validation is green, and no follow-up is needed.
|
||||
- `merge after checks`: likely safe, but wait for named CI jobs, dependency graph checks, or narrow validation.
|
||||
- `hold`: compatibility, licence, packaging, JVM, server API, or validation concerns need investigation or code changes.
|
||||
- `close/ignore`: update is incompatible, not useful for this branch, duplicates another update, or should be deferred by policy.
|
||||
|
||||
## Review output template
|
||||
|
||||
```markdown
|
||||
### Dependency review
|
||||
- Scope: production / compile-only / test-only / integration-test-only / GitHub Actions
|
||||
- Release notes checked:
|
||||
- Licence/packaging impact:
|
||||
- JVM and server compatibility:
|
||||
- Shading/duplicate class risk:
|
||||
- Validation:
|
||||
- Verdict: merge / merge after checks / hold / close/ignore
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: github-issue-investigation
|
||||
description: Use for GitHub issue triage and local investigation, including gh issue workflows, picking a fresh issue, reproducing issue reports, and planning regression coverage.
|
||||
---
|
||||
|
||||
# GitHub Issue Investigation
|
||||
|
||||
Use this skill when investigating GitHub issues locally, especially bug reports that may need reproduction and durable regression coverage before any fix is attempted.
|
||||
|
||||
## Default posture
|
||||
|
||||
- Treat GitHub as read-only by default.
|
||||
- `gh issue list` and `gh issue view` are allowed for investigation.
|
||||
- Do not comment, label, assign, close, edit milestones, or otherwise mutate public GitHub state unless the user explicitly asks later.
|
||||
- Keep findings chat-only by default; do not create plans, state files, or persistent notes unless the user asks.
|
||||
- Try to reproduce before fixing. Prefer a regression test that can remain in the repository when practical.
|
||||
- After local findings, let the user decide whether to fix, comment, or abandon the issue.
|
||||
|
||||
## When an issue number or URL is provided
|
||||
|
||||
Inspect that specific issue with `gh issue view`. Summarise:
|
||||
|
||||
- reported behaviour and expected behaviour;
|
||||
- environment, configuration, server, plugin, or version hints;
|
||||
- missing information that would affect local reproduction;
|
||||
- related code paths and existing tests worth checking;
|
||||
- a local reproduction recommendation, including the narrowest likely test or manual scenario.
|
||||
|
||||
Avoid changing GitHub state while doing this. If the issue is clearly a duplicate, support question, feature request, or waiting for reporter information, report that and explain whether local investigation is still worthwhile.
|
||||
|
||||
## When no issue is provided
|
||||
|
||||
Use `gh issue list` to build a shortlist rather than choosing silently. Present about 3-5 candidate issues with:
|
||||
|
||||
- issue number and title;
|
||||
- why the issue looks actionable;
|
||||
- likely affected area;
|
||||
- reproduction feasibility;
|
||||
- reasons to avoid it, if any.
|
||||
|
||||
Do not require or prefer positive labels. Actively avoid issues labelled exactly `awaiting testing` or effectively equivalent, because those are usually awaiting reporter or user verification rather than fresh local investigation.
|
||||
|
||||
Prefer issues with observable broken behaviour and enough detail to reproduce. Avoid issues that are obvious duplicates, support questions, feature requests, waiting on reporter information, already assigned or visibly in progress, dependent on unavailable external services, private servers, client-only behaviour, or anything requiring GitHub-side mutation before local investigation can proceed.
|
||||
|
||||
## Reproduction and coverage
|
||||
|
||||
- Reproduce before implementing a fix where practical.
|
||||
- Prefer integration tests when the behaviour depends on Bukkit/Paper server behaviour, plugin lifecycle, events, fake players, PacketEvents, or cross-version compatibility.
|
||||
- Use or trigger `integration-test-verification` for integration-test matrix selection, runs, Kotest registration, compact result triage, and failure investigation.
|
||||
- Respect the repository rule that root and user-facing agents must not read full `build/integration-test-logs/*.log` files directly; use compact summaries first and delegate log inspection only when needed.
|
||||
- If integration coverage is not feasible, explain why and propose the next best durable coverage, such as a focused unit-level test, synthetic event test, or documented manual reproduction.
|
||||
|
||||
## Routing after investigation
|
||||
|
||||
Keep this skill investigation-focused. If the user later requests a fix:
|
||||
|
||||
- use `TDD` for repro-test-first work;
|
||||
- use `Planner` when the fix needs planned multi-step work or broader design decisions;
|
||||
- use `BuildReview` for small standalone quick changes.
|
||||
|
||||
Do not start a fix, comment on GitHub, or create persistent planning artefacts merely because an issue looks actionable.
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: integration-test-verification
|
||||
description: Use when running, selecting, authoring, or triaging integration tests, Kotest specs, Gradle matrix tasks, FakePlayer-backed test cases, or compact test-result files; do not use for unrelated production-only edits, release publishing, or general PR prose.
|
||||
---
|
||||
|
||||
# Integration Test Verification
|
||||
|
||||
Use this skill for the repository's Paper-backed integration-test harness and for deciding how to validate a change without opening large server logs by default.
|
||||
|
||||
## When to use
|
||||
|
||||
- Selecting `./gradlew integrationTest` versus a version-limited matrix run.
|
||||
- Adding or changing Kotlin integration tests under `src/integrationTest/kotlin`.
|
||||
- Registering a new spec in `KotestRunner`.
|
||||
- Triaging `checkTestResults<version>` output or `plugins/OldCombatMechanicsTest/test-failures.txt`.
|
||||
- Reasoning about tests that use `FakePlayer`, synthetic Bukkit events, PacketEvents wrappers, or direct module handler calls.
|
||||
|
||||
## When not to use
|
||||
|
||||
- Do not use for release asset, Hangar, CurseForge, or licence checks; use `release-readiness-review`.
|
||||
- Do not use for module assignment or config migration design unless the task is specifically about test coverage; use `module-config-change`.
|
||||
- Do not use for broad Java 8/NMS design unless the immediate question is test-harness behaviour; use `compatibility-strategy`.
|
||||
|
||||
## Non-negotiable rules
|
||||
|
||||
- Root and user-facing agents must not read `build/integration-test-logs/*.log` directly; log inspection is allowed only by subagents, and only when needed for integration-test triage or when explicitly requested.
|
||||
- Prefer compact console output and `plugins/OldCombatMechanicsTest/test-failures.txt` first for failure triage.
|
||||
- Do not disable, skip, weaken, or version-gate tests as a way to make validation pass. Fix the root cause, or report the failure as a blocker unless the user explicitly approves a coverage reduction.
|
||||
- New integration specs must be added to `KotestRunner.withClasses(...)`; autoscan is disabled.
|
||||
- Keep paths relative to the repository root, plugin data folder, or server run directory.
|
||||
- Avoid starting servers for documentation-only or opencode-config-only changes.
|
||||
|
||||
## Harness quick reference
|
||||
|
||||
- Full matrix: `./gradlew integrationTest`.
|
||||
- Selected matrix: `./gradlew integrationTest -PintegrationTestVersions=1.19.2,1.21.11,1.12`.
|
||||
- Wrapper helper: `scripts/run-integration-matrix.sh -v 1.19.2,1.21.11 [-s '*SpecName*'] [-t '*test name*']` keeps Gradle and Kotest filter arguments consistent.
|
||||
- Compact result summary: `scripts/summarise-integration-results.sh` reads only `run/*/plugins/OldCombatMechanicsTest/test-results.txt` and `test-failures.txt`; it does not open full server logs.
|
||||
- Matrix suggestion helper: `scripts/suggest-integration-matrix.sh [changed-path ...]` gives a conservative starting point for versions and filters.
|
||||
- Individual task names follow `integrationTest<version>` with punctuation mapped to underscores, for example `integrationTest1_19_2`.
|
||||
- Test plugin entrypoint: `kernitus.plugin.OldCombatMechanics.OCMTestMain`.
|
||||
- Success handoff: `plugins/OldCombatMechanicsTest/test-results.txt` contains `PASS`.
|
||||
- Failure summary: `plugins/OldCombatMechanicsTest/test-failures.txt` contains a compact list, capped for CI readability.
|
||||
- Java 11+ server targets use Kotest 6 (`KotestRunner`, `KotestProjectConfig`).
|
||||
- Java 8 targets use `LegacyTestRunner`, currently only a smoke path.
|
||||
|
||||
## Validation selection template
|
||||
|
||||
1. Identify the changed feature and affected server bands.
|
||||
2. Prefer the narrowest version set that covers the compatibility boundary.
|
||||
3. If the change touches packet handling, include a modern PacketEvents-covered version.
|
||||
4. If the change touches legacy behaviour or Java 8 APIs, include `1.12` where practical.
|
||||
5. If the change is documentation or opencode-only, run filesystem/schema checks rather than server tests.
|
||||
|
||||
Example recommendation:
|
||||
|
||||
```text
|
||||
Run: ./gradlew integrationTest -PintegrationTestVersions=1.19.2,1.21.11
|
||||
Reason: covers modern Paper plus the PacketEvents path affected by the change.
|
||||
Avoid: opening build/integration-test-logs from a root or user-facing agent; use a subagent only when compact failure output is insufficient or log inspection is explicitly requested.
|
||||
```
|
||||
|
||||
## Known shortcut patterns
|
||||
|
||||
Some tests intentionally exercise module code without fully realistic gameplay plumbing:
|
||||
|
||||
- `GoldenAppleIntegrationTest`, `OldPotionEffectsIntegrationTest`, `OldArmourDurabilityIntegrationTest`, `PlayerKnockbackIntegrationTest`, `SwordBlockingIntegrationTest`, and `SwordSweepIntegrationTest` use synthetic events in places.
|
||||
- `PlayerKnockbackIntegrationTest` and `SwordSweepIntegrationTest` directly invoke module handlers.
|
||||
- Packet cancellation tests construct and dispatch PacketEvents events directly for deterministic coverage.
|
||||
|
||||
Treat these shortcuts as design choices unless the task asks for end-to-end realism.
|
||||
|
||||
## References
|
||||
|
||||
- Historical integration and regression notes: `references/relocated-agents-notes.md`.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Relocated AGENTS.md Historical Notes
|
||||
|
||||
This reference preserves detailed repository context that previously made the root `AGENTS.md` difficult to scan. Keep always-on routing rules in `AGENTS.md`; keep operational detail here or in a more specific skill.
|
||||
|
||||
## Integration test harness
|
||||
|
||||
- Integration tests live in `src/integrationTest/kotlin` and are packaged into `OldCombatMechanics-<version>-tests.jar`.
|
||||
- Entrypoint plugin class: `kernitus.plugin.OldCombatMechanics.OCMTestMain`.
|
||||
- Tests run inside a real Paper server started by the Gradle `run-paper` plugin.
|
||||
- RunServer output is redirected to `build/integration-test-logs/<version>.log`; `checkTestResults<version>` prints only a compact summary/failures to the console.
|
||||
- `KotestRunner` writes `plugins/OldCombatMechanicsTest/test-failures.txt` so CI can surface failure reasons without opening the full server log.
|
||||
- PacketEvents is shaded into the plugin; integration tests do not inject external packet libraries.
|
||||
- `relocateIntegrationTestClasses` relocates PacketEvents references in test classes only.
|
||||
- `integrationTestJar` embeds relocated test classes plus runtime dependencies, excluding PacketEvents so the test plugin resolves PacketEvents from the main plugin’s shaded copy.
|
||||
- `PacketCancellationIntegrationTest` uses a cancellable `CompletableFuture.await()` and drives PacketEvents via `PacketEventsImplHelper.handleClientBoundPacket` with a synthetic `User`.
|
||||
- `PacketCancellationIntegrationTest` sets the Bukkit player on the synthetic PacketEvents event so module listeners can match `PacketSendEvent#getPlayer`.
|
||||
- Matrix task: `integrationTest` depends on `integrationTestMatrix`.
|
||||
- `OCMTestMain` writes `plugins/OldCombatMechanicsTest/test-results.txt` containing `PASS` or `FAIL`.
|
||||
|
||||
## Version matrix and Java selection
|
||||
|
||||
- Config is in `build.gradle.kts`.
|
||||
- `integrationTestVersions` comes from the property or defaults.
|
||||
- `requiredJavaVersion` selects Java based on version.
|
||||
- Pre-1.13 versions use `integrationTestJavaVersionLegacyPre13` (default 8).
|
||||
- Modern versions use Java 25 if `>=1.20.5`, else Java 17.
|
||||
- Legacy vanilla jar cache: `downloadVanilla<version>` downloads Mojang server jars for `<=1.12` and writes `run/<version>/cache/mojang_<version>.jar`.
|
||||
- Paper 1.12 sometimes fails to download the legacy vanilla jar from old Mojang endpoints; the custom `downloadVanilla` task uses the v2 manifest.
|
||||
|
||||
## Kotlin runner split
|
||||
|
||||
- Kotest 6 (Java 11+) is used for 1.19.2 and 1.21.11.
|
||||
- Kotest runner class: `kernitus.plugin.OldCombatMechanics.KotestRunner`.
|
||||
- Project config: `KotestProjectConfig`.
|
||||
- Java 8 uses `kernitus.plugin.OldCombatMechanics.LegacyTestRunner`.
|
||||
- The Java 8 path is currently a smoke test only, verifying the plugin is enabled and `WeaponDamages` is loaded.
|
||||
|
||||
## Fake player notes
|
||||
|
||||
- Primary implementation: `src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/FakePlayer.kt`.
|
||||
- It uses `xyz.jpenilla:reflection-remapper` to map modern NMS names.
|
||||
- On legacy servers, mappings are unavailable and code falls back to `ReflectionRemapper.noop()`.
|
||||
- As-is, `FakePlayer` uses modern NMS class names; Paper 1.12 needs a dedicated fake-player path or version-aware class mapping.
|
||||
- Fake player implementations use simulated login/network plumbing with `EmbeddedChannel` and manual login/join/quit events, not a real networked client.
|
||||
- `FakePlayer` uses a plain `EmbeddedChannel` with dummy `decoder`/`encoder` handlers so PacketEvents treats it as fake and does not disallow login.
|
||||
- Modern fake players schedule a manual NMS tick, preferring `doTick`, then `tick`, then `baseTick`.
|
||||
- The tick shim invokes `baseTick` while burning to ensure fire tick damage events still occur on Paper 1.21+, and prefers normal ticking when in water so extinguish logic can run.
|
||||
- `FakePlayer` prefers `PlayerList.placeNewPlayer` and forces a world add when the Bukkit world does not report the fake player entity after placement.
|
||||
- `FakePlayer` clears invulnerability/instabuild abilities after spawn, plus a legacy fallback.
|
||||
|
||||
## Test harness shortcuts
|
||||
|
||||
- Synthetic event tests: `GoldenAppleIntegrationTest`, `OldPotionEffectsIntegrationTest`, `OldArmourDurabilityIntegrationTest`, `PlayerKnockbackIntegrationTest`, `SwordBlockingIntegrationTest`, and `SwordSweepIntegrationTest`.
|
||||
- Direct module handler tests: `PlayerKnockbackIntegrationTest` and `SwordSweepIntegrationTest`.
|
||||
- `AttributeModifierCompat` synthesises a fallback attack-damage modifier from `NewWeaponDamage` when API attributes are missing.
|
||||
|
||||
## Registration and notable test history
|
||||
|
||||
- `KotestRunner` must include each new integration test class explicitly.
|
||||
- `OldToolDamageMobIntegrationTest`, `WeaponDurabilityIntegrationTest`, `DisableOffhandIntegrationTest`, `ToolDamageTooltipIntegrationTest`, `PacketCancellationIntegrationTest`, `ModesetRulesIntegrationTest`, `ConfigMigrationIntegrationTest`, `ChorusFruitIntegrationTest`, `DisableOffhandReflectionIntegrationTest`, `AttackRangeIntegrationTest`, `PaperSwordBlockingDamageReductionIntegrationTest`, and `ConsumableComponentIntegrationTest` have all been registered historically.
|
||||
- `Kotest` filters (`kotest.filter.specs`, `kotest.filter.tests`) pass through Gradle into the run-paper JVM args.
|
||||
- `WeaponDurabilityIntegrationTest` and `OldToolDamageMobIntegrationTest` resolve debug paths relative to the repo root rather than hard-coded home paths.
|
||||
- `1.9` integration tests are currently on hold per user request.
|
||||
- 1.21.11 servers may log hostname and Unsafe warnings; tests can still pass.
|
||||
|
||||
## PacketEvents and packet cancellation history
|
||||
|
||||
- `ModuleSwordSweepParticles` and `ModuleAttackSounds` use PacketEvents listeners/wrappers instead of ProtocolLib.
|
||||
- `PacketCancellationIntegrationTest` builds a `PacketSendEvent` directly from the transformed buffer, sets packet id/type explicitly, and dispatches it through the PacketEvents event manager.
|
||||
- PacketEvents dependency moved to `2.11.2-SNAPSHOT` from CodeMC snapshots for 1.21.11 support.
|
||||
|
||||
## Sword blocking and consumable component history
|
||||
|
||||
- Paper-only sword blocking uses runtime-gated helper `kernitus.plugin.OldCombatMechanics.paper.PaperSwordBlocking` to apply consumable and blocking components via cached reflection.
|
||||
- 1.8-style reduction is applied via `EntityDamageByEntityListener` using the `BLOCKING` modifier, while legacy/non-Paper keeps the shield swap path.
|
||||
- `ModuleSwordBlocking` gates the Paper consumable animation by PacketEvents client version `>=1.20.5`; older clients fall back to offhand shield behaviour.
|
||||
- Unknown PacketEvents client versions fall back to shield behaviour only when a PacketEvents `User` exists; early-login/synthetic players keep animation support unless resolver failure requires fail-safe fallback.
|
||||
- Inventory-click and drag handlers follow a minimal-mutation policy; lifecycle and transition handlers clean stale consumable state.
|
||||
- Lifecycle cleanup covers modeset change, reload, world change, quit, join, held-slot changes, and swap paths.
|
||||
- `sword-blocking.paper-animation` can hard-disable the Paper animation path and force legacy shield fallback.
|
||||
- Legacy shield-drop reconciliation is safety-first and avoids guessing plain shield drops as temporary shields when marker APIs are unavailable.
|
||||
|
||||
## Fire aspect and fire tick notes
|
||||
|
||||
- `FireAspectOverdamageIntegrationTest` uses a Zombie victim for real fire tick sampling with boosted max health.
|
||||
- Initial tests fire synthetic `EntityDamageEvent` with `FIRE_TICK` for deterministic baseline checks.
|
||||
- Paper 1.12 applies attack-cooldown scaling before Bukkit damage events; tests wait a few ticks before the first attack to stabilise the first hit.
|
||||
- Extra coverage includes fire resistance, fire-immune victims, water extinguish behaviour, fire protection versus protection, and alternating attackers during invulnerability.
|
||||
- Legacy player-specific fire tick sampling differs, so player afterburn-vs-environment comparisons are no-ops on legacy; Zombie variants still run across versions.
|
||||
|
||||
## Miscellaneous historical fixes
|
||||
|
||||
- Removed dead reflection utility `ClassType` and unused `Reflector#getClass(ClassType, String)` overload.
|
||||
- `SpigotFunctionChooser` now only falls back for compatibility-style failures and rethrows ordinary runtime logic failures.
|
||||
- `AttackCompat` requires observable living-target hit signals for Bukkit `Player#attack` success and treats boolean-return NMS methods returning `false` as failed attempts.
|
||||
- Several modules replaced per-player/per-hit scheduled tasks with shared or lazy-pruned maps: sword blocking dedupe, fishing rod velocity, attack cooldown tracking, player knockback, shield damage reduction, old armour durability, player regen, disable enderpearl cooldown, and stored last-damage expiry.
|
||||
- `ModuleLoader` clears the static module list on initialise to prevent duplicate registrations after hot reloads.
|
||||
- `README` includes licence notes: source remains MPL-2.0, while pre-built jars bundling PacketEvents are distributed under GPLv3; builds without PacketEvents can remain MPL-2.0.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: module-config-change
|
||||
description: Use for config.yml, module enablement, modesets, config migration, configurable module assignment, and per-module option changes; do not use for unrelated integration-test triage, release publishing, or PR summaries.
|
||||
---
|
||||
|
||||
# Module Config Change
|
||||
|
||||
Use this skill when changing module configuration, defaults, migration, or runtime handling driven by `config.yml`.
|
||||
|
||||
## When to use
|
||||
|
||||
- Editing bundled `config.yml` defaults or comments.
|
||||
- Adding or changing module settings such as attack cooldown, attack range, sword blocking, old tool damage, tooltip, potion effects, or durability behaviour.
|
||||
- Changing `always_enabled_modules`, `disabled_modules`, modesets, or config migration logic.
|
||||
- Validating reload behaviour after config changes.
|
||||
- Writing tests around config assignment, migration, or modeset-specific behaviour.
|
||||
|
||||
## When not to use
|
||||
|
||||
- Do not use for ordinary test execution unless the test is config-specific; use `integration-test-verification`.
|
||||
- Do not use for Java/NMS compatibility strategy unless the config option depends on runtime feature detection; use `compatibility-strategy` as well.
|
||||
- Do not use for release workflow edits; use `release-readiness-review`.
|
||||
|
||||
## Mandatory configuration rules
|
||||
|
||||
- Configurable modules must appear in only one top-level assignment category: `always_enabled_modules`, `disabled_modules`, or the aggregate `modesets` category. The same module may appear in more than one individual modeset because modesets are alternative player modes.
|
||||
- Duplicate entries are still invalid within the same exact list or individual modeset.
|
||||
- Internal modules (`modeset-listener`, `attack-cooldown-tracker`, `entity-damage-listener`) are always enabled and must not be listed.
|
||||
- Reload/enable should fail for invalid module assignment.
|
||||
- Do not add per-module `enabled:` toggles; the module lists and modesets are authoritative.
|
||||
- Keep bundled comments clear for server owners and use British English.
|
||||
|
||||
## Change checklist
|
||||
|
||||
1. Identify whether the change is a default, migration, reload, or runtime behaviour change.
|
||||
2. Update bundled config comments and defaults together when needed.
|
||||
3. Preserve existing user config where migration should be non-destructive.
|
||||
4. Run `scripts/validate-module-assignments.sh` for a quick static check that bundled configurable lists do not conflict across `always_enabled_modules`, `disabled_modules`, and the aggregate `modesets` category; do not duplicate entries within the same exact list or modeset; and do not include internal modules.
|
||||
5. Add or update integration coverage where practical.
|
||||
6. If adding an integration spec, register it in `KotestRunner.withClasses(...)`.
|
||||
7. Validate both enabled and disabled/modeset-scoped behaviour for options that change item state or player state.
|
||||
|
||||
## Useful known behaviours
|
||||
|
||||
- `old-tool-damage.tooltip.enabled` is enabled by default so players can see configured damage in-game.
|
||||
- `attack-range` is listed in `disabled_modules` by default and is Paper 1.21.11+ only.
|
||||
- `sword-blocking.paper-animation` defaults to `true`; when false, sword blocking uses the legacy shield fallback and strips stale Paper consumable state.
|
||||
- Attack cooldown supports `disable-attack-cooldown.held-item-attack-speeds.<MATERIAL>` with XMaterial/Material matching and restores vanilla attack speed when disabled.
|
||||
|
||||
## Validation template
|
||||
|
||||
```text
|
||||
Config area:
|
||||
Default changed:
|
||||
Migration needed:
|
||||
Enabled-path validation:
|
||||
Disabled/modeset validation:
|
||||
KotestRunner registration needed:
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- Historical integration and module notes: `../integration-test-verification/references/relocated-agents-notes.md`.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: pr-draft-summary
|
||||
description: Use when drafting pull-request titles, descriptions, change summaries, risk notes, validation sections, or reviewer handoff text; do not use for implementation design, release publishing checks, or raw test failure triage.
|
||||
---
|
||||
|
||||
# PR Draft Summary
|
||||
|
||||
Use this skill to turn completed work into reviewer-facing prose. Keep wording concise, accurate, and in British English.
|
||||
|
||||
## When to use
|
||||
|
||||
- Drafting a PR title or body.
|
||||
- Summarising changes after a slice or branch of work.
|
||||
- Writing reviewer notes, risk notes, validation sections, or follow-up items.
|
||||
- Turning technical details into a changelog-style summary.
|
||||
|
||||
## When not to use
|
||||
|
||||
- Do not use before implementation decisions are made; use the relevant implementation skill first.
|
||||
- Do not use for release readiness unless the requested output is a PR description; use `release-readiness-review` for release checks.
|
||||
- Do not use to interpret raw integration test failures; use `integration-test-verification`.
|
||||
|
||||
## Preferred structure
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
-
|
||||
-
|
||||
|
||||
## Validation
|
||||
-
|
||||
|
||||
## Risks / notes
|
||||
-
|
||||
```
|
||||
|
||||
## Conventional commit prompt
|
||||
|
||||
When asked for a commit message, use conventional commits format, for example:
|
||||
|
||||
```text
|
||||
docs: add repo-local agent skill guidance
|
||||
```
|
||||
|
||||
## Reviewer handoff checklist
|
||||
|
||||
- Name the changed area before the mechanism.
|
||||
- Mention validation commands exactly as run.
|
||||
- If validation was not run, say why.
|
||||
- Separate behaviour changes from documentation/configuration changes.
|
||||
- Avoid overstating coverage; note known gaps plainly.
|
||||
|
||||
## Example phrasing
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
- Added repo-local agent skills for integration-test verification, compatibility strategy, module config changes, release readiness, and PR summaries.
|
||||
- Slimmed root `AGENTS.md` to always-on rules plus routing guidance, with historical details relocated into skill references.
|
||||
|
||||
## Validation
|
||||
- Checked skill frontmatter and folder-name matches.
|
||||
- Confirmed no plugin runtime files changed.
|
||||
|
||||
## Risks / notes
|
||||
- opencode must be restarted before new or changed skills are available in a running session.
|
||||
```
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: release-readiness-review
|
||||
description: Use for GitHub release, Hangar, CurseForge/BukkitDev upload, licence, asset naming, supported-version, and workflow readiness checks; do not use for day-to-day feature implementation, test authoring, or PR prose only.
|
||||
---
|
||||
|
||||
# Release Readiness Review
|
||||
|
||||
Use this skill before changing or reviewing release workflows, publishing metadata, supported game versions, licence notes, and final release assets.
|
||||
|
||||
## When to use
|
||||
|
||||
- Editing `.github/workflows/*release*` or publishing-related Gradle configuration.
|
||||
- Checking Hangar, CurseForge/BukkitDev, or GitHub release upload behaviour.
|
||||
- Reviewing licence implications of shaded dependencies such as PacketEvents.
|
||||
- Updating supported Minecraft versions, `gradle.properties` release metadata, or asset names.
|
||||
- Preparing a release-readiness checklist for maintainers.
|
||||
|
||||
## When not to use
|
||||
|
||||
- Do not use for ordinary implementation or test selection; use the feature-specific skill.
|
||||
- Do not use for PR description wording unless release notes are the main task; use `pr-draft-summary`.
|
||||
- Do not use for module config behaviour unless it affects packaging or documented release defaults.
|
||||
|
||||
## Current release notes
|
||||
|
||||
- GitHub release asset uses stable filename `OldCombatMechanics.jar` without a version suffix.
|
||||
- CurseForge upload uses the same path.
|
||||
- Bukkit-compatible CurseForge game-version entries use `1:<version>` prefixes so type-1 Bukkit versions are selected.
|
||||
- Hangar publish configuration expects `HANGAR_API_TOKEN`.
|
||||
- README licence note: source remains MPL-2.0; pre-built jars bundling PacketEvents are distributed under GPLv3; builds without PacketEvents can remain MPL-2.0.
|
||||
- `gradle.properties` game versions currently include 1.21.11 down to 1.21.1, plus 1.21, ahead of existing entries.
|
||||
|
||||
## Review checklist
|
||||
|
||||
1. Confirm secrets are referenced by environment variable or GitHub secret name, never hard-coded.
|
||||
2. Confirm the built jar path and uploaded asset path match.
|
||||
3. Confirm supported game versions map to the publishing platform’s expected identifiers.
|
||||
4. Confirm shaded dependency licence implications are reflected in release notes or README where needed.
|
||||
5. Confirm workflow changes do not alter plugin runtime behaviour.
|
||||
6. Prefer dry, static checks unless the user explicitly requests a release run.
|
||||
|
||||
## Release note template
|
||||
|
||||
```markdown
|
||||
### Release readiness
|
||||
- Asset: `OldCombatMechanics.jar`
|
||||
- Platforms checked: GitHub / Hangar / CurseForge
|
||||
- Version metadata checked:
|
||||
- Licence notes checked:
|
||||
- Secrets touched: none / list variable names only
|
||||
- Remaining manual steps:
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- Broader historical notes: `../integration-test-verification/references/relocated-agents-notes.md`.
|
||||
@@ -0,0 +1,642 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
max_line_length = 120
|
||||
tab_width = 4
|
||||
ij_continuation_indent_size = 8
|
||||
ij_formatter_off_tag = @formatter:off
|
||||
ij_formatter_on_tag = @formatter:on
|
||||
ij_formatter_tags_enabled = false
|
||||
ij_smart_tabs = false
|
||||
ij_visual_guides = none
|
||||
ij_wrap_on_typing = false
|
||||
|
||||
[*.css]
|
||||
ij_css_align_closing_brace_with_properties = false
|
||||
ij_css_blank_lines_around_nested_selector = 1
|
||||
ij_css_blank_lines_between_blocks = 1
|
||||
ij_css_brace_placement = end_of_line
|
||||
ij_css_enforce_quotes_on_format = false
|
||||
ij_css_hex_color_long_format = false
|
||||
ij_css_hex_color_lower_case = false
|
||||
ij_css_hex_color_short_format = false
|
||||
ij_css_hex_color_upper_case = false
|
||||
ij_css_keep_blank_lines_in_code = 2
|
||||
ij_css_keep_indents_on_empty_lines = false
|
||||
ij_css_keep_single_line_blocks = false
|
||||
ij_css_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
|
||||
ij_css_space_after_colon = true
|
||||
ij_css_space_before_opening_brace = true
|
||||
ij_css_use_double_quotes = true
|
||||
ij_css_value_alignment = do_not_align
|
||||
|
||||
[*.feature]
|
||||
indent_size = 2
|
||||
ij_gherkin_keep_indents_on_empty_lines = false
|
||||
|
||||
[*.gsp]
|
||||
ij_gsp_keep_indents_on_empty_lines = false
|
||||
|
||||
[*.haml]
|
||||
indent_size = 2
|
||||
ij_haml_keep_indents_on_empty_lines = false
|
||||
|
||||
[*.java]
|
||||
ij_java_align_consecutive_assignments = false
|
||||
ij_java_align_consecutive_variable_declarations = false
|
||||
ij_java_align_group_field_declarations = false
|
||||
ij_java_align_multiline_annotation_parameters = false
|
||||
ij_java_align_multiline_array_initializer_expression = false
|
||||
ij_java_align_multiline_assignment = false
|
||||
ij_java_align_multiline_binary_operation = false
|
||||
ij_java_align_multiline_chained_methods = false
|
||||
ij_java_align_multiline_extends_list = false
|
||||
ij_java_align_multiline_for = true
|
||||
ij_java_align_multiline_method_parentheses = false
|
||||
ij_java_align_multiline_parameters = true
|
||||
ij_java_align_multiline_parameters_in_calls = false
|
||||
ij_java_align_multiline_parenthesized_expression = false
|
||||
ij_java_align_multiline_records = true
|
||||
ij_java_align_multiline_resources = true
|
||||
ij_java_align_multiline_ternary_operation = false
|
||||
ij_java_align_multiline_text_blocks = false
|
||||
ij_java_align_multiline_throws_list = false
|
||||
ij_java_align_subsequent_simple_methods = false
|
||||
ij_java_align_throws_keyword = false
|
||||
ij_java_annotation_parameter_wrap = off
|
||||
ij_java_array_initializer_new_line_after_left_brace = false
|
||||
ij_java_array_initializer_right_brace_on_new_line = false
|
||||
ij_java_array_initializer_wrap = off
|
||||
ij_java_assert_statement_colon_on_next_line = false
|
||||
ij_java_assert_statement_wrap = off
|
||||
ij_java_assignment_wrap = off
|
||||
ij_java_binary_operation_sign_on_next_line = false
|
||||
ij_java_binary_operation_wrap = off
|
||||
ij_java_blank_lines_after_anonymous_class_header = 0
|
||||
ij_java_blank_lines_after_class_header = 0
|
||||
ij_java_blank_lines_after_imports = 1
|
||||
ij_java_blank_lines_after_package = 1
|
||||
ij_java_blank_lines_around_class = 1
|
||||
ij_java_blank_lines_around_field = 0
|
||||
ij_java_blank_lines_around_field_in_interface = 0
|
||||
ij_java_blank_lines_around_initializer = 1
|
||||
ij_java_blank_lines_around_method = 1
|
||||
ij_java_blank_lines_around_method_in_interface = 1
|
||||
ij_java_blank_lines_before_class_end = 0
|
||||
ij_java_blank_lines_before_imports = 1
|
||||
ij_java_blank_lines_before_method_body = 0
|
||||
ij_java_blank_lines_before_package = 0
|
||||
ij_java_block_brace_style = end_of_line
|
||||
ij_java_block_comment_at_first_column = true
|
||||
ij_java_call_parameters_new_line_after_left_paren = false
|
||||
ij_java_call_parameters_right_paren_on_new_line = false
|
||||
ij_java_call_parameters_wrap = off
|
||||
ij_java_case_statement_on_separate_line = true
|
||||
ij_java_catch_on_new_line = false
|
||||
ij_java_class_annotation_wrap = split_into_lines
|
||||
ij_java_class_brace_style = end_of_line
|
||||
ij_java_class_count_to_use_import_on_demand = 5
|
||||
ij_java_class_names_in_javadoc = 1
|
||||
ij_java_do_not_indent_top_level_class_members = false
|
||||
ij_java_do_not_wrap_after_single_annotation = false
|
||||
ij_java_do_while_brace_force = never
|
||||
ij_java_doc_add_blank_line_after_description = true
|
||||
ij_java_doc_add_blank_line_after_param_comments = false
|
||||
ij_java_doc_add_blank_line_after_return = false
|
||||
ij_java_doc_add_p_tag_on_empty_lines = true
|
||||
ij_java_doc_align_exception_comments = true
|
||||
ij_java_doc_align_param_comments = true
|
||||
ij_java_doc_do_not_wrap_if_one_line = false
|
||||
ij_java_doc_enable_formatting = true
|
||||
ij_java_doc_enable_leading_asterisks = true
|
||||
ij_java_doc_indent_on_continuation = false
|
||||
ij_java_doc_keep_empty_lines = true
|
||||
ij_java_doc_keep_empty_parameter_tag = true
|
||||
ij_java_doc_keep_empty_return_tag = true
|
||||
ij_java_doc_keep_empty_throws_tag = true
|
||||
ij_java_doc_keep_invalid_tags = true
|
||||
ij_java_doc_param_description_on_new_line = false
|
||||
ij_java_doc_preserve_line_breaks = false
|
||||
ij_java_doc_use_throws_not_exception_tag = true
|
||||
ij_java_else_on_new_line = false
|
||||
ij_java_entity_dd_suffix = EJB
|
||||
ij_java_entity_eb_suffix = Bean
|
||||
ij_java_entity_hi_suffix = Home
|
||||
ij_java_entity_lhi_prefix = Local
|
||||
ij_java_entity_lhi_suffix = Home
|
||||
ij_java_entity_li_prefix = Local
|
||||
ij_java_entity_pk_class = java.lang.String
|
||||
ij_java_entity_vo_suffix = VO
|
||||
ij_java_enum_constants_wrap = off
|
||||
ij_java_extends_keyword_wrap = off
|
||||
ij_java_extends_list_wrap = off
|
||||
ij_java_field_annotation_wrap = split_into_lines
|
||||
ij_java_finally_on_new_line = false
|
||||
ij_java_for_brace_force = never
|
||||
ij_java_for_statement_new_line_after_left_paren = false
|
||||
ij_java_for_statement_right_paren_on_new_line = false
|
||||
ij_java_for_statement_wrap = off
|
||||
ij_java_generate_final_locals = false
|
||||
ij_java_generate_final_parameters = false
|
||||
ij_java_if_brace_force = never
|
||||
ij_java_imports_layout = *,|,javax.**,java.**,|,$*
|
||||
ij_java_indent_case_from_switch = true
|
||||
ij_java_insert_inner_class_imports = false
|
||||
ij_java_insert_override_annotation = true
|
||||
ij_java_keep_blank_lines_before_right_brace = 2
|
||||
ij_java_keep_blank_lines_between_package_declaration_and_header = 2
|
||||
ij_java_keep_blank_lines_in_code = 2
|
||||
ij_java_keep_blank_lines_in_declarations = 2
|
||||
ij_java_keep_control_statement_in_one_line = true
|
||||
ij_java_keep_first_column_comment = true
|
||||
ij_java_keep_indents_on_empty_lines = false
|
||||
ij_java_keep_line_breaks = true
|
||||
ij_java_keep_multiple_expressions_in_one_line = false
|
||||
ij_java_keep_simple_blocks_in_one_line = false
|
||||
ij_java_keep_simple_classes_in_one_line = false
|
||||
ij_java_keep_simple_lambdas_in_one_line = false
|
||||
ij_java_keep_simple_methods_in_one_line = false
|
||||
ij_java_label_indent_absolute = false
|
||||
ij_java_label_indent_size = 0
|
||||
ij_java_lambda_brace_style = end_of_line
|
||||
ij_java_layout_static_imports_separately = true
|
||||
ij_java_line_comment_add_space = false
|
||||
ij_java_line_comment_at_first_column = true
|
||||
ij_java_message_dd_suffix = EJB
|
||||
ij_java_message_eb_suffix = Bean
|
||||
ij_java_method_annotation_wrap = split_into_lines
|
||||
ij_java_method_brace_style = end_of_line
|
||||
ij_java_method_call_chain_wrap = off
|
||||
ij_java_method_parameters_new_line_after_left_paren = false
|
||||
ij_java_method_parameters_right_paren_on_new_line = false
|
||||
ij_java_method_parameters_wrap = off
|
||||
ij_java_modifier_list_wrap = false
|
||||
ij_java_names_count_to_use_import_on_demand = 3
|
||||
ij_java_new_line_after_lparen_in_record_header = false
|
||||
ij_java_packages_to_use_import_on_demand = java.awt.*,javax.swing.*
|
||||
ij_java_parameter_annotation_wrap = off
|
||||
ij_java_parentheses_expression_new_line_after_left_paren = false
|
||||
ij_java_parentheses_expression_right_paren_on_new_line = false
|
||||
ij_java_place_assignment_sign_on_next_line = false
|
||||
ij_java_prefer_longer_names = true
|
||||
ij_java_prefer_parameters_wrap = false
|
||||
ij_java_record_components_wrap = normal
|
||||
ij_java_repeat_synchronized = true
|
||||
ij_java_replace_instanceof_and_cast = false
|
||||
ij_java_replace_null_check = true
|
||||
ij_java_replace_sum_lambda_with_method_ref = true
|
||||
ij_java_resource_list_new_line_after_left_paren = false
|
||||
ij_java_resource_list_right_paren_on_new_line = false
|
||||
ij_java_resource_list_wrap = off
|
||||
ij_java_rparen_on_new_line_in_record_header = false
|
||||
ij_java_session_dd_suffix = EJB
|
||||
ij_java_session_eb_suffix = Bean
|
||||
ij_java_session_hi_suffix = Home
|
||||
ij_java_session_lhi_prefix = Local
|
||||
ij_java_session_lhi_suffix = Home
|
||||
ij_java_session_li_prefix = Local
|
||||
ij_java_session_si_suffix = Service
|
||||
ij_java_space_after_closing_angle_bracket_in_type_argument = false
|
||||
ij_java_space_after_colon = true
|
||||
ij_java_space_after_comma = true
|
||||
ij_java_space_after_comma_in_type_arguments = true
|
||||
ij_java_space_after_for_semicolon = true
|
||||
ij_java_space_after_quest = true
|
||||
ij_java_space_after_type_cast = true
|
||||
ij_java_space_before_annotation_array_initializer_left_brace = false
|
||||
ij_java_space_before_annotation_parameter_list = false
|
||||
ij_java_space_before_array_initializer_left_brace = false
|
||||
ij_java_space_before_catch_keyword = true
|
||||
ij_java_space_before_catch_left_brace = true
|
||||
ij_java_space_before_catch_parentheses = true
|
||||
ij_java_space_before_class_left_brace = true
|
||||
ij_java_space_before_colon = true
|
||||
ij_java_space_before_colon_in_foreach = true
|
||||
ij_java_space_before_comma = false
|
||||
ij_java_space_before_do_left_brace = true
|
||||
ij_java_space_before_else_keyword = true
|
||||
ij_java_space_before_else_left_brace = true
|
||||
ij_java_space_before_finally_keyword = true
|
||||
ij_java_space_before_finally_left_brace = true
|
||||
ij_java_space_before_for_left_brace = true
|
||||
ij_java_space_before_for_parentheses = true
|
||||
ij_java_space_before_for_semicolon = false
|
||||
ij_java_space_before_if_left_brace = true
|
||||
ij_java_space_before_if_parentheses = true
|
||||
ij_java_space_before_method_call_parentheses = false
|
||||
ij_java_space_before_method_left_brace = true
|
||||
ij_java_space_before_method_parentheses = false
|
||||
ij_java_space_before_opening_angle_bracket_in_type_parameter = false
|
||||
ij_java_space_before_quest = true
|
||||
ij_java_space_before_switch_left_brace = true
|
||||
ij_java_space_before_switch_parentheses = true
|
||||
ij_java_space_before_synchronized_left_brace = true
|
||||
ij_java_space_before_synchronized_parentheses = true
|
||||
ij_java_space_before_try_left_brace = true
|
||||
ij_java_space_before_try_parentheses = true
|
||||
ij_java_space_before_type_parameter_list = false
|
||||
ij_java_space_before_while_keyword = true
|
||||
ij_java_space_before_while_left_brace = true
|
||||
ij_java_space_before_while_parentheses = true
|
||||
ij_java_space_inside_one_line_enum_braces = false
|
||||
ij_java_space_within_empty_array_initializer_braces = false
|
||||
ij_java_space_within_empty_method_call_parentheses = false
|
||||
ij_java_space_within_empty_method_parentheses = false
|
||||
ij_java_spaces_around_additive_operators = true
|
||||
ij_java_spaces_around_assignment_operators = true
|
||||
ij_java_spaces_around_bitwise_operators = true
|
||||
ij_java_spaces_around_equality_operators = true
|
||||
ij_java_spaces_around_lambda_arrow = true
|
||||
ij_java_spaces_around_logical_operators = true
|
||||
ij_java_spaces_around_method_ref_dbl_colon = false
|
||||
ij_java_spaces_around_multiplicative_operators = true
|
||||
ij_java_spaces_around_relational_operators = true
|
||||
ij_java_spaces_around_shift_operators = true
|
||||
ij_java_spaces_around_type_bounds_in_type_parameters = true
|
||||
ij_java_spaces_around_unary_operator = false
|
||||
ij_java_spaces_within_angle_brackets = false
|
||||
ij_java_spaces_within_annotation_parentheses = false
|
||||
ij_java_spaces_within_array_initializer_braces = false
|
||||
ij_java_spaces_within_braces = false
|
||||
ij_java_spaces_within_brackets = false
|
||||
ij_java_spaces_within_cast_parentheses = false
|
||||
ij_java_spaces_within_catch_parentheses = false
|
||||
ij_java_spaces_within_for_parentheses = false
|
||||
ij_java_spaces_within_if_parentheses = false
|
||||
ij_java_spaces_within_method_call_parentheses = false
|
||||
ij_java_spaces_within_method_parentheses = false
|
||||
ij_java_spaces_within_parentheses = false
|
||||
ij_java_spaces_within_record_header = false
|
||||
ij_java_spaces_within_switch_parentheses = false
|
||||
ij_java_spaces_within_synchronized_parentheses = false
|
||||
ij_java_spaces_within_try_parentheses = false
|
||||
ij_java_spaces_within_while_parentheses = false
|
||||
ij_java_special_else_if_treatment = true
|
||||
ij_java_subclass_name_suffix = Impl
|
||||
ij_java_ternary_operation_signs_on_next_line = false
|
||||
ij_java_ternary_operation_wrap = off
|
||||
ij_java_test_name_suffix = Test
|
||||
ij_java_throws_keyword_wrap = off
|
||||
ij_java_throws_list_wrap = off
|
||||
ij_java_use_external_annotations = false
|
||||
ij_java_use_fq_class_names = false
|
||||
ij_java_use_relative_indents = false
|
||||
ij_java_use_single_class_imports = true
|
||||
ij_java_variable_annotation_wrap = off
|
||||
ij_java_visibility = public
|
||||
ij_java_while_brace_force = never
|
||||
ij_java_while_on_new_line = false
|
||||
ij_java_wrap_comments = false
|
||||
ij_java_wrap_first_method_in_call_chain = false
|
||||
ij_java_wrap_long_lines = false
|
||||
|
||||
[.editorconfig]
|
||||
ij_editorconfig_align_group_field_declarations = false
|
||||
ij_editorconfig_space_after_colon = false
|
||||
ij_editorconfig_space_after_comma = true
|
||||
ij_editorconfig_space_before_colon = false
|
||||
ij_editorconfig_space_before_comma = false
|
||||
ij_editorconfig_spaces_around_assignment_operators = true
|
||||
|
||||
[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.pom,*.qrc,*.rng,*.tld,*.wadl,*.wsdd,*.wsdl,*.xjb,*.xml,*.xsd,*.xsl,*.xslt,*.xul}]
|
||||
ij_xml_align_attributes = true
|
||||
ij_xml_align_text = false
|
||||
ij_xml_attribute_wrap = normal
|
||||
ij_xml_block_comment_at_first_column = true
|
||||
ij_xml_keep_blank_lines = 2
|
||||
ij_xml_keep_indents_on_empty_lines = false
|
||||
ij_xml_keep_line_breaks = true
|
||||
ij_xml_keep_line_breaks_in_text = true
|
||||
ij_xml_keep_whitespaces = false
|
||||
ij_xml_keep_whitespaces_around_cdata = preserve
|
||||
ij_xml_keep_whitespaces_inside_cdata = false
|
||||
ij_xml_line_comment_at_first_column = true
|
||||
ij_xml_space_after_tag_name = false
|
||||
ij_xml_space_around_equals_in_attribute = false
|
||||
ij_xml_space_inside_empty_tag = false
|
||||
ij_xml_text_wrap = normal
|
||||
ij_xml_use_custom_settings = false
|
||||
|
||||
[{*.ft,*.vm,*.vsl}]
|
||||
ij_vtl_keep_indents_on_empty_lines = false
|
||||
|
||||
[{*.gant,*.gradle,*.groovy,*.gson,*.gy}]
|
||||
ij_groovy_align_group_field_declarations = false
|
||||
ij_groovy_align_multiline_array_initializer_expression = false
|
||||
ij_groovy_align_multiline_assignment = false
|
||||
ij_groovy_align_multiline_binary_operation = false
|
||||
ij_groovy_align_multiline_chained_methods = false
|
||||
ij_groovy_align_multiline_extends_list = false
|
||||
ij_groovy_align_multiline_for = true
|
||||
ij_groovy_align_multiline_list_or_map = true
|
||||
ij_groovy_align_multiline_method_parentheses = false
|
||||
ij_groovy_align_multiline_parameters = true
|
||||
ij_groovy_align_multiline_parameters_in_calls = false
|
||||
ij_groovy_align_multiline_resources = true
|
||||
ij_groovy_align_multiline_ternary_operation = false
|
||||
ij_groovy_align_multiline_throws_list = false
|
||||
ij_groovy_align_named_args_in_map = true
|
||||
ij_groovy_align_throws_keyword = false
|
||||
ij_groovy_array_initializer_new_line_after_left_brace = false
|
||||
ij_groovy_array_initializer_right_brace_on_new_line = false
|
||||
ij_groovy_array_initializer_wrap = off
|
||||
ij_groovy_assert_statement_wrap = off
|
||||
ij_groovy_assignment_wrap = off
|
||||
ij_groovy_binary_operation_wrap = off
|
||||
ij_groovy_blank_lines_after_class_header = 0
|
||||
ij_groovy_blank_lines_after_imports = 1
|
||||
ij_groovy_blank_lines_after_package = 1
|
||||
ij_groovy_blank_lines_around_class = 1
|
||||
ij_groovy_blank_lines_around_field = 0
|
||||
ij_groovy_blank_lines_around_field_in_interface = 0
|
||||
ij_groovy_blank_lines_around_method = 1
|
||||
ij_groovy_blank_lines_around_method_in_interface = 1
|
||||
ij_groovy_blank_lines_before_imports = 1
|
||||
ij_groovy_blank_lines_before_method_body = 0
|
||||
ij_groovy_blank_lines_before_package = 0
|
||||
ij_groovy_block_brace_style = end_of_line
|
||||
ij_groovy_block_comment_at_first_column = true
|
||||
ij_groovy_call_parameters_new_line_after_left_paren = false
|
||||
ij_groovy_call_parameters_right_paren_on_new_line = false
|
||||
ij_groovy_call_parameters_wrap = off
|
||||
ij_groovy_catch_on_new_line = false
|
||||
ij_groovy_class_annotation_wrap = split_into_lines
|
||||
ij_groovy_class_brace_style = end_of_line
|
||||
ij_groovy_class_count_to_use_import_on_demand = 5
|
||||
ij_groovy_do_while_brace_force = never
|
||||
ij_groovy_else_on_new_line = false
|
||||
ij_groovy_enum_constants_wrap = off
|
||||
ij_groovy_extends_keyword_wrap = off
|
||||
ij_groovy_extends_list_wrap = off
|
||||
ij_groovy_field_annotation_wrap = split_into_lines
|
||||
ij_groovy_finally_on_new_line = false
|
||||
ij_groovy_for_brace_force = never
|
||||
ij_groovy_for_statement_new_line_after_left_paren = false
|
||||
ij_groovy_for_statement_right_paren_on_new_line = false
|
||||
ij_groovy_for_statement_wrap = off
|
||||
ij_groovy_if_brace_force = never
|
||||
ij_groovy_import_annotation_wrap = 2
|
||||
ij_groovy_imports_layout = *,|,javax.**,java.**,|,$*
|
||||
ij_groovy_indent_case_from_switch = true
|
||||
ij_groovy_indent_label_blocks = true
|
||||
ij_groovy_insert_inner_class_imports = false
|
||||
ij_groovy_keep_blank_lines_before_right_brace = 2
|
||||
ij_groovy_keep_blank_lines_in_code = 2
|
||||
ij_groovy_keep_blank_lines_in_declarations = 2
|
||||
ij_groovy_keep_control_statement_in_one_line = true
|
||||
ij_groovy_keep_first_column_comment = true
|
||||
ij_groovy_keep_indents_on_empty_lines = false
|
||||
ij_groovy_keep_line_breaks = true
|
||||
ij_groovy_keep_multiple_expressions_in_one_line = false
|
||||
ij_groovy_keep_simple_blocks_in_one_line = false
|
||||
ij_groovy_keep_simple_classes_in_one_line = true
|
||||
ij_groovy_keep_simple_lambdas_in_one_line = true
|
||||
ij_groovy_keep_simple_methods_in_one_line = true
|
||||
ij_groovy_label_indent_absolute = false
|
||||
ij_groovy_label_indent_size = 0
|
||||
ij_groovy_lambda_brace_style = end_of_line
|
||||
ij_groovy_layout_static_imports_separately = true
|
||||
ij_groovy_line_comment_add_space = false
|
||||
ij_groovy_line_comment_at_first_column = true
|
||||
ij_groovy_method_annotation_wrap = split_into_lines
|
||||
ij_groovy_method_brace_style = end_of_line
|
||||
ij_groovy_method_call_chain_wrap = off
|
||||
ij_groovy_method_parameters_new_line_after_left_paren = false
|
||||
ij_groovy_method_parameters_right_paren_on_new_line = false
|
||||
ij_groovy_method_parameters_wrap = off
|
||||
ij_groovy_modifier_list_wrap = false
|
||||
ij_groovy_names_count_to_use_import_on_demand = 3
|
||||
ij_groovy_parameter_annotation_wrap = off
|
||||
ij_groovy_parentheses_expression_new_line_after_left_paren = false
|
||||
ij_groovy_parentheses_expression_right_paren_on_new_line = false
|
||||
ij_groovy_prefer_parameters_wrap = false
|
||||
ij_groovy_resource_list_new_line_after_left_paren = false
|
||||
ij_groovy_resource_list_right_paren_on_new_line = false
|
||||
ij_groovy_resource_list_wrap = off
|
||||
ij_groovy_space_after_assert_separator = true
|
||||
ij_groovy_space_after_colon = true
|
||||
ij_groovy_space_after_comma = true
|
||||
ij_groovy_space_after_comma_in_type_arguments = true
|
||||
ij_groovy_space_after_for_semicolon = true
|
||||
ij_groovy_space_after_quest = true
|
||||
ij_groovy_space_after_type_cast = true
|
||||
ij_groovy_space_before_annotation_parameter_list = false
|
||||
ij_groovy_space_before_array_initializer_left_brace = false
|
||||
ij_groovy_space_before_assert_separator = false
|
||||
ij_groovy_space_before_catch_keyword = true
|
||||
ij_groovy_space_before_catch_left_brace = true
|
||||
ij_groovy_space_before_catch_parentheses = true
|
||||
ij_groovy_space_before_class_left_brace = true
|
||||
ij_groovy_space_before_closure_left_brace = true
|
||||
ij_groovy_space_before_colon = true
|
||||
ij_groovy_space_before_comma = false
|
||||
ij_groovy_space_before_do_left_brace = true
|
||||
ij_groovy_space_before_else_keyword = true
|
||||
ij_groovy_space_before_else_left_brace = true
|
||||
ij_groovy_space_before_finally_keyword = true
|
||||
ij_groovy_space_before_finally_left_brace = true
|
||||
ij_groovy_space_before_for_left_brace = true
|
||||
ij_groovy_space_before_for_parentheses = true
|
||||
ij_groovy_space_before_for_semicolon = false
|
||||
ij_groovy_space_before_if_left_brace = true
|
||||
ij_groovy_space_before_if_parentheses = true
|
||||
ij_groovy_space_before_method_call_parentheses = false
|
||||
ij_groovy_space_before_method_left_brace = true
|
||||
ij_groovy_space_before_method_parentheses = false
|
||||
ij_groovy_space_before_quest = true
|
||||
ij_groovy_space_before_switch_left_brace = true
|
||||
ij_groovy_space_before_switch_parentheses = true
|
||||
ij_groovy_space_before_synchronized_left_brace = true
|
||||
ij_groovy_space_before_synchronized_parentheses = true
|
||||
ij_groovy_space_before_try_left_brace = true
|
||||
ij_groovy_space_before_try_parentheses = true
|
||||
ij_groovy_space_before_while_keyword = true
|
||||
ij_groovy_space_before_while_left_brace = true
|
||||
ij_groovy_space_before_while_parentheses = true
|
||||
ij_groovy_space_in_named_argument = true
|
||||
ij_groovy_space_in_named_argument_before_colon = false
|
||||
ij_groovy_space_within_empty_array_initializer_braces = false
|
||||
ij_groovy_space_within_empty_method_call_parentheses = false
|
||||
ij_groovy_spaces_around_additive_operators = true
|
||||
ij_groovy_spaces_around_assignment_operators = true
|
||||
ij_groovy_spaces_around_bitwise_operators = true
|
||||
ij_groovy_spaces_around_equality_operators = true
|
||||
ij_groovy_spaces_around_lambda_arrow = true
|
||||
ij_groovy_spaces_around_logical_operators = true
|
||||
ij_groovy_spaces_around_multiplicative_operators = true
|
||||
ij_groovy_spaces_around_regex_operators = true
|
||||
ij_groovy_spaces_around_relational_operators = true
|
||||
ij_groovy_spaces_around_shift_operators = true
|
||||
ij_groovy_spaces_within_annotation_parentheses = false
|
||||
ij_groovy_spaces_within_array_initializer_braces = false
|
||||
ij_groovy_spaces_within_braces = true
|
||||
ij_groovy_spaces_within_brackets = false
|
||||
ij_groovy_spaces_within_cast_parentheses = false
|
||||
ij_groovy_spaces_within_catch_parentheses = false
|
||||
ij_groovy_spaces_within_for_parentheses = false
|
||||
ij_groovy_spaces_within_gstring_injection_braces = false
|
||||
ij_groovy_spaces_within_if_parentheses = false
|
||||
ij_groovy_spaces_within_list_or_map = false
|
||||
ij_groovy_spaces_within_method_call_parentheses = false
|
||||
ij_groovy_spaces_within_method_parentheses = false
|
||||
ij_groovy_spaces_within_parentheses = false
|
||||
ij_groovy_spaces_within_switch_parentheses = false
|
||||
ij_groovy_spaces_within_synchronized_parentheses = false
|
||||
ij_groovy_spaces_within_try_parentheses = false
|
||||
ij_groovy_spaces_within_tuple_expression = false
|
||||
ij_groovy_spaces_within_while_parentheses = false
|
||||
ij_groovy_special_else_if_treatment = true
|
||||
ij_groovy_ternary_operation_wrap = off
|
||||
ij_groovy_throws_keyword_wrap = off
|
||||
ij_groovy_throws_list_wrap = off
|
||||
ij_groovy_use_flying_geese_braces = false
|
||||
ij_groovy_use_fq_class_names = false
|
||||
ij_groovy_use_fq_class_names_in_javadoc = true
|
||||
ij_groovy_use_relative_indents = false
|
||||
ij_groovy_use_single_class_imports = true
|
||||
ij_groovy_variable_annotation_wrap = off
|
||||
ij_groovy_while_brace_force = never
|
||||
ij_groovy_while_on_new_line = false
|
||||
ij_groovy_wrap_long_lines = false
|
||||
|
||||
[{*.gradle.kts,*.kt,*.kts,*.main.kts}]
|
||||
ij_kotlin_align_in_columns_case_branch = false
|
||||
ij_kotlin_align_multiline_binary_operation = false
|
||||
ij_kotlin_align_multiline_extends_list = false
|
||||
ij_kotlin_align_multiline_method_parentheses = false
|
||||
ij_kotlin_align_multiline_parameters = true
|
||||
ij_kotlin_align_multiline_parameters_in_calls = false
|
||||
ij_kotlin_allow_trailing_comma = false
|
||||
ij_kotlin_allow_trailing_comma_on_call_site = false
|
||||
ij_kotlin_assignment_wrap = normal
|
||||
ij_kotlin_blank_lines_after_class_header = 0
|
||||
ij_kotlin_blank_lines_around_block_when_branches = 0
|
||||
ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1
|
||||
ij_kotlin_block_comment_at_first_column = true
|
||||
ij_kotlin_call_parameters_new_line_after_left_paren = true
|
||||
ij_kotlin_call_parameters_right_paren_on_new_line = true
|
||||
ij_kotlin_call_parameters_wrap = on_every_item
|
||||
ij_kotlin_catch_on_new_line = false
|
||||
ij_kotlin_class_annotation_wrap = split_into_lines
|
||||
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
|
||||
ij_kotlin_continuation_indent_for_chained_calls = false
|
||||
ij_kotlin_continuation_indent_for_expression_bodies = false
|
||||
ij_kotlin_continuation_indent_in_argument_lists = false
|
||||
ij_kotlin_continuation_indent_in_elvis = false
|
||||
ij_kotlin_continuation_indent_in_if_conditions = false
|
||||
ij_kotlin_continuation_indent_in_parameter_lists = false
|
||||
ij_kotlin_continuation_indent_in_supertype_lists = false
|
||||
ij_kotlin_else_on_new_line = false
|
||||
ij_kotlin_enum_constants_wrap = off
|
||||
ij_kotlin_extends_list_wrap = normal
|
||||
ij_kotlin_field_annotation_wrap = split_into_lines
|
||||
ij_kotlin_finally_on_new_line = false
|
||||
ij_kotlin_if_rparen_on_new_line = true
|
||||
ij_kotlin_import_nested_classes = false
|
||||
ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^
|
||||
ij_kotlin_insert_whitespaces_in_simple_one_line_method = true
|
||||
ij_kotlin_keep_blank_lines_before_right_brace = 2
|
||||
ij_kotlin_keep_blank_lines_in_code = 2
|
||||
ij_kotlin_keep_blank_lines_in_declarations = 2
|
||||
ij_kotlin_keep_first_column_comment = true
|
||||
ij_kotlin_keep_indents_on_empty_lines = false
|
||||
ij_kotlin_keep_line_breaks = true
|
||||
ij_kotlin_lbrace_on_next_line = false
|
||||
ij_kotlin_line_comment_add_space = false
|
||||
ij_kotlin_line_comment_at_first_column = true
|
||||
ij_kotlin_method_annotation_wrap = split_into_lines
|
||||
ij_kotlin_method_call_chain_wrap = normal
|
||||
ij_kotlin_method_parameters_new_line_after_left_paren = true
|
||||
ij_kotlin_method_parameters_right_paren_on_new_line = true
|
||||
ij_kotlin_method_parameters_wrap = on_every_item
|
||||
ij_kotlin_name_count_to_use_star_import = 5
|
||||
ij_kotlin_name_count_to_use_star_import_for_members = 3
|
||||
ij_kotlin_packages_to_use_import_on_demand = java.util.*,kotlinx.android.synthetic.**,io.ktor.**
|
||||
ij_kotlin_parameter_annotation_wrap = off
|
||||
ij_kotlin_space_after_comma = true
|
||||
ij_kotlin_space_after_extend_colon = true
|
||||
ij_kotlin_space_after_type_colon = true
|
||||
ij_kotlin_space_before_catch_parentheses = true
|
||||
ij_kotlin_space_before_comma = false
|
||||
ij_kotlin_space_before_extend_colon = true
|
||||
ij_kotlin_space_before_for_parentheses = true
|
||||
ij_kotlin_space_before_if_parentheses = true
|
||||
ij_kotlin_space_before_lambda_arrow = true
|
||||
ij_kotlin_space_before_type_colon = false
|
||||
ij_kotlin_space_before_when_parentheses = true
|
||||
ij_kotlin_space_before_while_parentheses = true
|
||||
ij_kotlin_spaces_around_additive_operators = true
|
||||
ij_kotlin_spaces_around_assignment_operators = true
|
||||
ij_kotlin_spaces_around_equality_operators = true
|
||||
ij_kotlin_spaces_around_function_type_arrow = true
|
||||
ij_kotlin_spaces_around_logical_operators = true
|
||||
ij_kotlin_spaces_around_multiplicative_operators = true
|
||||
ij_kotlin_spaces_around_range = false
|
||||
ij_kotlin_spaces_around_relational_operators = true
|
||||
ij_kotlin_spaces_around_unary_operator = false
|
||||
ij_kotlin_spaces_around_when_arrow = true
|
||||
ij_kotlin_variable_annotation_wrap = off
|
||||
ij_kotlin_while_on_new_line = false
|
||||
ij_kotlin_wrap_elvis_expressions = 1
|
||||
ij_kotlin_wrap_expression_body_functions = 1
|
||||
ij_kotlin_wrap_first_method_in_call_chain = false
|
||||
|
||||
[{*.htm,*.html,*.ng,*.sht,*.shtm,*.shtml}]
|
||||
ij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3
|
||||
ij_html_align_attributes = true
|
||||
ij_html_align_text = false
|
||||
ij_html_attribute_wrap = normal
|
||||
ij_html_block_comment_at_first_column = true
|
||||
ij_html_do_not_align_children_of_min_lines = 0
|
||||
ij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6,p
|
||||
ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot
|
||||
ij_html_enforce_quotes = false
|
||||
ij_html_inline_tags = a,abbr,acronym,b,basefont,bdo,big,br,cite,cite,code,dfn,em,font,i,img,input,kbd,label,q,s,samp,select,small,span,strike,strong,sub,sup,textarea,tt,u,var
|
||||
ij_html_keep_blank_lines = 2
|
||||
ij_html_keep_indents_on_empty_lines = false
|
||||
ij_html_keep_line_breaks = true
|
||||
ij_html_keep_line_breaks_in_text = true
|
||||
ij_html_keep_whitespaces = false
|
||||
ij_html_keep_whitespaces_inside = span,pre,textarea
|
||||
ij_html_line_comment_at_first_column = true
|
||||
ij_html_new_line_after_last_attribute = never
|
||||
ij_html_new_line_before_first_attribute = never
|
||||
ij_html_quote_style = double
|
||||
ij_html_remove_new_line_before_tags = br
|
||||
ij_html_space_after_tag_name = false
|
||||
ij_html_space_around_equality_in_attribute = false
|
||||
ij_html_space_inside_empty_tag = false
|
||||
ij_html_text_wrap = normal
|
||||
ij_html_uniform_ident = false
|
||||
|
||||
[{*.markdown,*.md}]
|
||||
ij_markdown_force_one_space_after_blockquote_symbol = true
|
||||
ij_markdown_force_one_space_after_header_symbol = true
|
||||
ij_markdown_force_one_space_after_list_bullet = true
|
||||
ij_markdown_force_one_space_between_words = true
|
||||
ij_markdown_keep_indents_on_empty_lines = false
|
||||
ij_markdown_max_lines_around_block_elements = 1
|
||||
ij_markdown_max_lines_around_header = 1
|
||||
ij_markdown_max_lines_between_paragraphs = 1
|
||||
ij_markdown_min_lines_around_block_elements = 1
|
||||
ij_markdown_min_lines_around_header = 1
|
||||
ij_markdown_min_lines_between_paragraphs = 1
|
||||
|
||||
[{*.properties,spring.handlers,spring.schemas}]
|
||||
ij_properties_align_group_field_declarations = false
|
||||
ij_properties_keep_blank_lines = false
|
||||
ij_properties_key_value_delimiter = equals
|
||||
ij_properties_spaces_around_key_value_delimiter = false
|
||||
|
||||
[{*.yaml,*.yml}]
|
||||
indent_size = 2
|
||||
ij_yaml_keep_indents_on_empty_lines = false
|
||||
ij_yaml_keep_line_breaks = true
|
||||
ij_yaml_space_before_colon = true
|
||||
ij_yaml_spaces_within_braces = true
|
||||
ij_yaml_spaces_within_brackets = true
|
||||
@@ -0,0 +1 @@
|
||||
.gitea/CODEOWNERS linguist-detectable=false
|
||||
@@ -0,0 +1,3 @@
|
||||
# Pull requests request review from the eligible staff team(s).
|
||||
# One approval from either an Admin or Developer satisfies branch protection.
|
||||
* @trueog/Admins @trueog/Developers
|
||||
@@ -1,30 +1,57 @@
|
||||
# Contributing
|
||||
All contributions should be in the form of [pull requests](https://github.com/kernitus/BukkitOldCombatMechanics/pulls), and should use the formatting profiles provided at the bottom of this page. All pull requests must be fully functional and able to compile, and should be fully tested.
|
||||
<!--
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
## Formatting profiles
|
||||
### IntelliJ
|
||||
```xml
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<codeStyleSettings language="JAVA">
|
||||
<option name="SPACE_BEFORE_IF_PARENTHESES" value="false" />
|
||||
<option name="SPACE_BEFORE_WHILE_PARENTHESES" value="false" />
|
||||
<option name="SPACE_BEFORE_FOR_PARENTHESES" value="false" />
|
||||
<option name="SPACE_BEFORE_TRY_PARENTHESES" value="false" />
|
||||
<option name="SPACE_BEFORE_CATCH_PARENTHESES" value="false" />
|
||||
<option name="SPACE_BEFORE_SWITCH_PARENTHESES" value="false" />
|
||||
<option name="SPACE_BEFORE_SYNCHRONIZED_PARENTHESES" value="false" />
|
||||
<option name="SPACE_BEFORE_METHOD_LBRACE" value="false" />
|
||||
<option name="SPACE_BEFORE_IF_LBRACE" value="false" />
|
||||
<option name="SPACE_BEFORE_WHILE_LBRACE" value="false" />
|
||||
<option name="SPACE_BEFORE_FOR_LBRACE" value="false" />
|
||||
<option name="SPACE_BEFORE_SWITCH_LBRACE" value="false" />
|
||||
<option name="SPACE_BEFORE_TRY_LBRACE" value="false" />
|
||||
<option name="SPACE_BEFORE_CATCH_LBRACE" value="false" />
|
||||
<option name="SPACE_BEFORE_SYNCHRONIZED_LBRACE" value="false" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
# Contributing
|
||||
All contributions should be in the form of [pull requests](https://github.com/kernitus/BukkitOldCombatMechanics/pulls), and should follow the formatting guidance below. All pull requests must be fully functional and able to compile, and should be fully tested. Please submit **monofocal** pull requests, i.e.if you're making unrelated changes to two different modules, or decide to also update the version of a dependency, *those should be separate pull requests*.
|
||||
|
||||
## Testing expectations
|
||||
Contributions are expected to include automated test coverage within the existing test framework where practical. For behaviour changes, bug fixes, and new features, prefer adding or updating tests in the existing Kotlin integration-test harness rather than relying on manual testing alone.
|
||||
|
||||
## Language expectations
|
||||
New classes should be written in Kotlin by default. If you are modifying an existing Java class, keep the surrounding file consistent unless there is a clear reason to migrate it as part of the same change.
|
||||
|
||||
## Local setup
|
||||
Install [pre-commit](https://pre-commit.com/) with your preferred Python package manager, for example:
|
||||
|
||||
```shell
|
||||
pipx install pre-commit
|
||||
```
|
||||
### Eclipse
|
||||
If you are using Eclipse, we would welcome someone to port our formatter profile over to Eclipse. If you would like to do so, please open an issue with the Eclipse formatter profile, or DM Rayzr522#9429 on Discord with the profile.
|
||||
|
||||
Then install and test the repository hooks:
|
||||
|
||||
```shell
|
||||
pre-commit install
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
Before committing or opening a pull request, run:
|
||||
|
||||
```shell
|
||||
./gradlew spotlessCheck
|
||||
./gradlew check
|
||||
```
|
||||
|
||||
Also run the relevant integration tests when behaviour, compatibility, or bug-fix work warrants them, such as `./gradlew integrationTest` or a targeted task like `./gradlew integrationTest1_19_2`.
|
||||
|
||||
## Module system
|
||||
OldCombatMechanics uses a modular system to make sure each feature is completely independent from any other, and can be toggled off and have no impact on server performance. This means each new module must extend the `Module` class, and implement a public constructor which takes an instance of the plugin and passes the module name to the superconstructor. The `Module` class also provides an overloadable `reload()` method which is called whenever the plugin is reloaded. If you are using any class-level variables they should probably be updated in here. This should also be used for any initialisation that might need to be done if the config section is changed and `ocm reload` is called. You may then call `reload()` from the constructor.
|
||||
|
||||
## Module naming
|
||||
The name specified in the module constructor must be the same as the one used in the config.yml. The module name must meaningfully describe the purpose of the module, for example `disable-offhand` or `old-burn-delay`. The module classes should thus be respectively named `ModuleDisableOffhand` and `ModuleOldBurnDelay`. As you can see, kebab case is used for the constructor and config name, while pascal case prefixed by `Module` is used for class names.
|
||||
|
||||
## Module configuration
|
||||
All module configuration is done through the Module class in a subsection of the config.yml for each module. To access config variables under the module section, you can use the methods provided by `module()`, such as `module().getBoolean("enableBlue")`. The module config section must contain an `enabled` boolean key which is used by the module system to selectively register/unregister the module, and, if applicable, a `worlds: []` list key to configure which worlds your module will work in. It is the responsibility of the module to use the `module().isEnabled(world)` to make sure this is enforced.
|
||||
|
||||
## Code style
|
||||
There is relative freedom when it codes to coding style, but please adhere to the following guidelines:
|
||||
* Use `final` for variables whenever possible. This makes it much less likely to accidentally change a variable and improves code readability.
|
||||
* Use `if !condition return;` pattern to avoid eccessive nested if-statements. That is, to check multiple prerequisite conditions, invert the condition and return, and after returning continue with the code.
|
||||
* Use internal utilies where possible. There is a `Messenger` class for sending messages to users or console, a `Reflector` class for simple reflection, and `DualVersionedMaterial` for support across minecraft versions where the Bukkit item names have changed. There are many more in the `utilities` package, the usage of which can be found in already existing modules.
|
||||
|
||||
## Formatting
|
||||
The repository root `.editorconfig` is the source of truth for IntelliJ and EditorConfig-aware editors. Use `./gradlew spotlessCheck` to verify formatting and `./gradlew spotlessApply` to apply formatting fixes.
|
||||
|
||||
Java is not formatted with Google Java Format. Continue to follow the repository IntelliJ/EditorConfig style for Java changes.
|
||||
@@ -1,14 +0,0 @@
|
||||
<!-- Before reporting, please try the latest test version from here https://ci.rayzr.tech/job/OldCombatMechanics/ as often bugs have already been fixed there. ->
|
||||
|
||||
<!-- Please fill out all fields as applicable, providing as many details as possible -->
|
||||
|
||||
## Info
|
||||
|
||||
<!-- For the last two, please post links to a pastebin.com upload of the file -->
|
||||
- Server version:
|
||||
- OldCombatMechanics version:
|
||||
- Server log file:
|
||||
- OldCombatMechanics config file:
|
||||
|
||||
## Problem Description
|
||||
>
|
||||
@@ -0,0 +1,24 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gradle"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
ignore:
|
||||
- dependency-name: "io.netty:netty-all"
|
||||
# Kotest 6 requires JDK/JVM 11+, while legacy integration-test servers still
|
||||
# load the test plugin on Java 8; keep Kotest 5.x updates allowed until the
|
||||
# harness is split or migrated.
|
||||
- dependency-name: "io.kotest:kotest-runner-junit5-jvm"
|
||||
versions: ["[6,)"]
|
||||
- dependency-name: "io.kotest:kotest-assertions-core-jvm"
|
||||
versions: ["[6,)"]
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
@@ -0,0 +1 @@
|
||||
{".":"2.5.0"}
|
||||
@@ -0,0 +1,91 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# This permission is still required by the new action
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'adopt'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
gradle-version: wrapper
|
||||
|
||||
- name: Run Spotless Check
|
||||
run: |
|
||||
./gradlew spotlessCheck
|
||||
|
||||
# Build Step - No changes needed here
|
||||
- name: Run Gradle Build
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "release" ]; then
|
||||
VERSION=${{ github.event.release.tag_name }}
|
||||
VERSION=${VERSION#v} # Strip 'v' if present
|
||||
./gradlew clean build -Pversion=$VERSION
|
||||
else
|
||||
./gradlew clean build
|
||||
fi
|
||||
|
||||
- name: Upload Artifact to GitHub Release
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
# The 'files' input takes a path to the asset(s) you want to upload.
|
||||
# Keep a stable filename for external download links.
|
||||
files: ./build/libs/OldCombatMechanics.jar
|
||||
|
||||
- name: Read game versions from gradle.properties
|
||||
run: |
|
||||
RAW=$(grep ^gameVersions gradle.properties | cut -d'=' -f2-)
|
||||
# Convert "1.21, 1.20.6" -> "1:1.21,1:1.20.6" to select Bukkit-compatible typeId 1 entries via the minecraft endpoint
|
||||
GAME_VERSIONS=$(echo "$RAW" | tr -d ' ' | awk -F',' '{
|
||||
out="";
|
||||
for (i=1; i<=NF; i++) {
|
||||
v=$i;
|
||||
if (v!= "") {
|
||||
if (out!= "") out=out ",";
|
||||
out=out "1:" v;
|
||||
}
|
||||
}
|
||||
print out;
|
||||
}')
|
||||
echo "GAME_VERSIONS=$GAME_VERSIONS" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Upload to CurseForge (Bukkit)
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
uses: itsmeow/curseforge-upload@v3
|
||||
with:
|
||||
token: ${{ secrets.DBO_UPLOAD_API_TOKEN }}
|
||||
project_id: '98233'
|
||||
game_endpoint: 'minecraft'
|
||||
file_path: './build/libs/OldCombatMechanics.jar'
|
||||
changelog: ${{ github.event.release.body }}
|
||||
changelog_type: 'markdown'
|
||||
release_type: 'release'
|
||||
display_name: 'OldCombatMechanics ${{ github.event.release.tag_name }}'
|
||||
game_versions: ${{ env.GAME_VERSIONS }}
|
||||
|
||||
|
||||
- name: Publish to Hangar
|
||||
env:
|
||||
HANGAR_API_TOKEN: ${{ secrets.HANGAR_API_TOKEN }}
|
||||
HANGAR_CHANGELOG: ${{ github.event.release.body }}
|
||||
run: ./gradlew build publishPluginPublicationToHangar --stacktrace
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Dev builds
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- 'ingametesting'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'adopt'
|
||||
cache: gradle
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
gradle-version: wrapper
|
||||
|
||||
- name: Determine if this is a release version
|
||||
run: |
|
||||
IS_RELEASE=$(./gradlew -q printIsRelease)
|
||||
echo "Is release? $IS_RELEASE"
|
||||
echo "IS_RELEASE=$IS_RELEASE" >> $GITHUB_ENV
|
||||
|
||||
- name: Run Spotless Check
|
||||
run: |
|
||||
./gradlew spotlessCheck
|
||||
|
||||
- name: Run Gradle Build
|
||||
run: |
|
||||
./gradlew clean build
|
||||
|
||||
- name: Archive jar file
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: OldCombatMechanics
|
||||
path: build/libs/OldCombatMechanics.jar
|
||||
|
||||
- name: Publish Snapshot to Hangar
|
||||
if: ${{ github.event_name == 'push' && github.event.pull_request == null && env.IS_RELEASE != 'true' }}
|
||||
env:
|
||||
HANGAR_API_TOKEN: ${{ secrets.HANGAR_API_TOKEN }}
|
||||
run: |
|
||||
./gradlew publishPluginPublicationToHangar
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: release-please
|
||||
uses: googleapis/release-please-action@v5
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
config-file: .github/release-please-config.json
|
||||
manifest-file: .github/release-please-manifest.json
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Wrap issue form code blocks
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
wrap:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
// Avoid loops: when this workflow updates the issue, it triggers "edited" again.
|
||||
if (context.actor === "github-actions[bot]") return;
|
||||
|
||||
const issue = context.payload.issue;
|
||||
if (!issue || !issue.body) return;
|
||||
|
||||
// On edits, bail unless the body actually changed
|
||||
if (context.payload.action === "edited") {
|
||||
const changed = context.payload.changes && context.payload.changes.body;
|
||||
if (!changed) return;
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function wrapFirstCodeBlockAfterHeading(body, headingText, summaryText) {
|
||||
const re = new RegExp(
|
||||
`(^###\\s+${escapeRegExp(headingText)}\\s*\\n+)(\`\`\`[\\s\\S]*?\\n\`\`\`)(?=\\n|$)`,
|
||||
"m"
|
||||
);
|
||||
|
||||
return body.replace(re, (_m, heading, codeblock) => {
|
||||
return (
|
||||
`${heading}` +
|
||||
`<details>\n` +
|
||||
`<summary>${summaryText}</summary>\n\n` +
|
||||
`${codeblock}\n\n` +
|
||||
`</details>`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let body = issue.body;
|
||||
|
||||
body = wrapFirstCodeBlockAfterHeading(body, "Server Log File", "Server log");
|
||||
body = wrapFirstCodeBlockAfterHeading(body, "OldCombatMechanics config.yml", "config.yml");
|
||||
|
||||
if (body === issue.body) return;
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body
|
||||
});
|
||||
|
||||
@@ -19,4 +19,13 @@ out
|
||||
builds
|
||||
target
|
||||
META-INF
|
||||
dependency-reduced-pom.xml
|
||||
build/
|
||||
.kotlin/
|
||||
.gradle/
|
||||
.gradle-user/
|
||||
.gradle-cache/
|
||||
.gradle-local/
|
||||
kls-classpath
|
||||
kls_database.db
|
||||
run/
|
||||
bin/
|
||||
@@ -0,0 +1,9 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: spotless-check
|
||||
name: Spotless check
|
||||
entry: ./gradlew spotlessCheck
|
||||
language: system
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
@@ -0,0 +1,517 @@
|
||||
# OldCombatMechanics API
|
||||
|
||||
The OldCombatMechanics API exposes both configured modesets and temporary per-player module overrides. Prefer modesets for arena and minigame combat modes, such as offering separate 1.8-style and modern PvP experiences. Modesets are named configured bundles, selected per player and world, and changes made through `setModesetForPlayer` use the normal player-data persistence.
|
||||
|
||||
Module overrides are different: they are temporary API-only per-player exceptions for individual modules, intended for narrow runtime cases where a plugin needs to force one module on or off for one currently online player. Overrides are online-session-only, in-memory state. They are cleared when the player quits, when OldCombatMechanics is disabled, or when a plugin explicitly clears them through the API. Module overrides are not a replacement for configured modesets.
|
||||
|
||||
| Use case | Modesets | Module overrides |
|
||||
| --- | --- | --- |
|
||||
| Arena/minigame combat style, such as 1.8-style versus modern PvP | Preferred: configure named bundles and select them per player/world | Not intended for this |
|
||||
| Persistence | Uses normal player-data persistence when changed through `setModesetForPlayer` | Online-session-only, in-memory |
|
||||
| Scope | Whole configured bundle of module choices | Individual module exception for one player |
|
||||
| Configuration source | Server configuration and allowed world modesets | API-only runtime state |
|
||||
|
||||
Overrides are shared runtime state. If multiple plugins set an override for the same player and module, the last write wins. Any plugin that clears that override clears the same shared state for that player and module.
|
||||
|
||||
The API also exposes configured modesets, world-allowed modesets, the player's stored modeset for their current world, modeset changes, and Bukkit events for module override and modeset changes.
|
||||
|
||||
---
|
||||
|
||||
## Dependency setup
|
||||
|
||||
There is currently no separate slim API artefact. Compile against the same OldCombatMechanics plugin jar that you deploy or test with, and mark it as compile-only/provided so your plugin does not shade or bundle OCM.
|
||||
|
||||
Gradle Kotlin DSL with a local jar:
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
compileOnly(files("libs/OldCombatMechanics.jar"))
|
||||
compileOnly("org.spigotmc:spigot-api:1.21.11-R0.1-SNAPSHOT")
|
||||
}
|
||||
```
|
||||
|
||||
Maven with a local or system-provided jar:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>kernitus.plugin</groupId>
|
||||
<artifactId>OldCombatMechanics</artifactId>
|
||||
<version>your-ocm-version</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
If you use a local Maven repository or an internal repository manager, publish the OCM jar there and keep the dependency `provided`/`compileOnly`.
|
||||
|
||||
Do not use OCM internal packages. Public API classes live under:
|
||||
|
||||
```java
|
||||
import kernitus.plugin.OldCombatMechanics.api.OldCombatMechanicsAPI;
|
||||
import kernitus.plugin.OldCombatMechanics.api.PlayerModesetChangeEvent;
|
||||
import kernitus.plugin.OldCombatMechanics.api.PlayerModuleOverride;
|
||||
import kernitus.plugin.OldCombatMechanics.api.PlayerModuleOverrideChangeEvent;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `plugin.yml` load order
|
||||
|
||||
Use `depend` when your plugin cannot enable or function without OCM:
|
||||
|
||||
```yaml
|
||||
depend: [OldCombatMechanics]
|
||||
```
|
||||
|
||||
Use `softdepend` when OCM integration is optional and your plugin can run without it:
|
||||
|
||||
```yaml
|
||||
softdepend: [OldCombatMechanics]
|
||||
```
|
||||
|
||||
With `softdepend`, always treat the API service as optional and disable only your OCM-specific integration if the service is absent.
|
||||
|
||||
---
|
||||
|
||||
## Getting the API instance
|
||||
|
||||
Always null-check the Bukkit service registration before using the API. The service is only available while OldCombatMechanics is loaded and enabled.
|
||||
|
||||
```java
|
||||
RegisteredServiceProvider<OldCombatMechanicsAPI> registration = Bukkit.getServicesManager()
|
||||
.getRegistration(OldCombatMechanicsAPI.class);
|
||||
if (registration == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
OldCombatMechanicsAPI api = registration.getProvider();
|
||||
```
|
||||
|
||||
Kotlin callers should use the same service lookup and null-check the registration:
|
||||
|
||||
```kotlin
|
||||
val api = Bukkit.getServicesManager()
|
||||
.getRegistration(OldCombatMechanicsAPI::class.java)
|
||||
?.provider
|
||||
?: return
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Threading
|
||||
|
||||
Call API methods from the Bukkit main server thread. The current contract does not guarantee async-safe access to Bukkit `Player` state, module state-change notifications, or future implementation details unless a future method explicitly documents different threading behaviour.
|
||||
|
||||
---
|
||||
|
||||
## Module names
|
||||
|
||||
All methods that accept a `moduleName` parameter expect a value from the set returned by [`getModuleNames()`](#getmodulenames). Only configurable modules are valid. Internal modules, such as listener/helper modules, cannot be overridden and are not returned by `getModuleNames()`.
|
||||
|
||||
Passing an unknown or non-configurable module name throws `IllegalArgumentException`.
|
||||
|
||||
---
|
||||
|
||||
## Effective module precedence
|
||||
|
||||
When OldCombatMechanics determines whether a configurable module is enabled for a player, it resolves state in this order:
|
||||
|
||||
1. per-player `FORCE_ENABLED` or `FORCE_DISABLED` override;
|
||||
2. configured disabled modules;
|
||||
3. configured always-enabled modules;
|
||||
4. player modeset and world configuration.
|
||||
|
||||
`PlayerModuleOverride.DEFAULT` means no per-player override is set, so the configured rules above continue to decide the module state.
|
||||
|
||||
---
|
||||
|
||||
## Methods
|
||||
|
||||
### `getModesetNames`
|
||||
|
||||
Returns all configured modeset names in config iteration order.
|
||||
```java
|
||||
Set<String> getModesetNames()
|
||||
```
|
||||
```java
|
||||
for (String modeset : api.getModesetNames()) {
|
||||
player.sendMessage(modeset);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `getAllowedModesets`
|
||||
|
||||
Returns the modesets allowed in a world as an unmodifiable defensive copy.
|
||||
```java
|
||||
Set<String> getAllowedModesets(World world)
|
||||
```
|
||||
|
||||
Iteration order is meaningful: it follows the configured world list when present, the `worlds.__default__` list for worlds without their own list, or the configured modeset order when no world list applies.
|
||||
|
||||
There is no public API for a default or fallback modeset. If you need to present a default choice, use your own policy, such as the player's stored modeset when present or the first entry from `getAllowedModesets(world)`.
|
||||
|
||||
```java
|
||||
Set<String> allowed = api.getAllowedModesets(player.getWorld());
|
||||
for (String modeset : allowed) {
|
||||
player.sendMessage("Allowed here: " + modeset);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `getModesetForPlayer`
|
||||
|
||||
Returns the stored modeset for the player in their current world, or `null` when no player-specific modeset has been stored yet.
|
||||
```java
|
||||
String getModesetForPlayer(Player player)
|
||||
```
|
||||
```java
|
||||
String modeset = api.getModesetForPlayer(player);
|
||||
if (modeset == null) {
|
||||
player.sendMessage("No modeset selected for this world.");
|
||||
} else {
|
||||
player.sendMessage("Current modeset: " + modeset);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `setModesetForPlayer`
|
||||
|
||||
Stores the modeset for the player in their current world and fires `PlayerModesetChangeEvent` with reason `API` when the stored value changes.
|
||||
```java
|
||||
void setModesetForPlayer(Player player, String modesetName)
|
||||
```
|
||||
|
||||
The input name is normalised before validation and storage. Unknown modesets, or modesets not allowed in the player's current world, throw `IllegalArgumentException`.
|
||||
|
||||
Modeset changes made through the API follow normal player data persistence semantics: OCM stores the selected modeset in player data and schedules the usual save. They are not session-only overrides.
|
||||
|
||||
```java
|
||||
if (api.getAllowedModesets(player.getWorld()).contains("arena")) {
|
||||
api.setModesetForPlayer(player, "arena");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `forceEnableModuleForPlayer`
|
||||
|
||||
Forces a configurable module on for an online player until the override is cleared, the player quits, or OldCombatMechanics is disabled.
|
||||
```java
|
||||
void forceEnableModuleForPlayer(Player player, String moduleName)
|
||||
```
|
||||
|
||||
**Throws:** `IllegalArgumentException` if `moduleName` is unknown or non-configurable.
|
||||
```java
|
||||
api.forceEnableModuleForPlayer(player, "attack-frequency");
|
||||
```
|
||||
|
||||
Kotlin:
|
||||
```kotlin
|
||||
api.forceEnableModuleForPlayer(player, "attack-frequency")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `forceDisableModuleForPlayer`
|
||||
|
||||
Forces a configurable module off for an online player until the override is cleared, the player quits, or OldCombatMechanics is disabled.
|
||||
```java
|
||||
void forceDisableModuleForPlayer(Player player, String moduleName)
|
||||
```
|
||||
|
||||
**Throws:** `IllegalArgumentException` if `moduleName` is unknown or non-configurable.
|
||||
```java
|
||||
api.forceDisableModuleForPlayer(player, "sword-blocking");
|
||||
```
|
||||
|
||||
Kotlin:
|
||||
```kotlin
|
||||
api.forceDisableModuleForPlayer(player, "sword-blocking")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `clearModuleOverrideForPlayer`
|
||||
|
||||
Clears the override for a single configurable module, reverting the player to configured module behaviour. No-op if no override is set.
|
||||
```java
|
||||
void clearModuleOverrideForPlayer(Player player, String moduleName)
|
||||
```
|
||||
|
||||
**Throws:** `IllegalArgumentException` if `moduleName` is unknown or non-configurable.
|
||||
```java
|
||||
api.clearModuleOverrideForPlayer(player, "attack-frequency");
|
||||
```
|
||||
|
||||
Kotlin:
|
||||
```kotlin
|
||||
api.clearModuleOverrideForPlayer(player, "attack-frequency")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `clearAllModuleOverridesForPlayer`
|
||||
|
||||
Clears all module overrides for a player at once, reverting them to configured module behaviour for every module. No-op if no overrides are set.
|
||||
```java
|
||||
void clearAllModuleOverridesForPlayer(Player player)
|
||||
```
|
||||
```java
|
||||
api.clearAllModuleOverridesForPlayer(player);
|
||||
```
|
||||
|
||||
Kotlin:
|
||||
```kotlin
|
||||
api.clearAllModuleOverridesForPlayer(player)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `getModuleOverrideForPlayer`
|
||||
|
||||
Returns the current override state for a single configurable module for a player. Returns `PlayerModuleOverride.DEFAULT` if no override is set.
|
||||
```java
|
||||
PlayerModuleOverride getModuleOverrideForPlayer(Player player, String moduleName)
|
||||
```
|
||||
|
||||
**Throws:** `IllegalArgumentException` if `moduleName` is unknown or non-configurable.
|
||||
```java
|
||||
PlayerModuleOverride override = api.getModuleOverrideForPlayer(player, "sword-blocking");
|
||||
if (override == PlayerModuleOverride.FORCE_ENABLED) {
|
||||
player.sendMessage("Forced on");
|
||||
} else if (override == PlayerModuleOverride.FORCE_DISABLED) {
|
||||
player.sendMessage("Forced off");
|
||||
} else {
|
||||
player.sendMessage("Following configured rules");
|
||||
}
|
||||
```
|
||||
|
||||
Kotlin:
|
||||
```kotlin
|
||||
when (api.getModuleOverrideForPlayer(player, "sword-blocking")) {
|
||||
PlayerModuleOverride.FORCE_ENABLED -> player.sendMessage("Forced on")
|
||||
PlayerModuleOverride.FORCE_DISABLED -> player.sendMessage("Forced off")
|
||||
PlayerModuleOverride.DEFAULT -> player.sendMessage("Following configured rules")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `getModuleOverridesForPlayer`
|
||||
|
||||
Returns all active overrides for a player as a map of module name to `PlayerModuleOverride`. Only modules with a non-`DEFAULT` override are included.
|
||||
```java
|
||||
Map<String, PlayerModuleOverride> getModuleOverridesForPlayer(Player player)
|
||||
```
|
||||
```java
|
||||
Map<String, PlayerModuleOverride> overrides = api.getModuleOverridesForPlayer(player);
|
||||
overrides.forEach((module, override) ->
|
||||
player.sendMessage(module + ": " + override)
|
||||
);
|
||||
```
|
||||
|
||||
Kotlin:
|
||||
```kotlin
|
||||
api.getModuleOverridesForPlayer(player).forEach { (module, override) ->
|
||||
player.sendMessage("$module: $override")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `setModuleOverridesForPlayer`
|
||||
|
||||
Sets multiple module overrides for a player at once. Entries with `PlayerModuleOverride.DEFAULT` are treated as clears. All module names and override values are validated before any override state changes, so an invalid entry leaves existing overrides unchanged.
|
||||
```java
|
||||
void setModuleOverridesForPlayer(Player player, Map<String, PlayerModuleOverride> overrides)
|
||||
```
|
||||
|
||||
**Throws:** `IllegalArgumentException` if any module name is unknown or non-configurable, or if a Java caller supplies a null module name or override value.
|
||||
```java
|
||||
Map<String, PlayerModuleOverride> overrides = new HashMap<>();
|
||||
overrides.put("attack-frequency", PlayerModuleOverride.FORCE_ENABLED);
|
||||
overrides.put("sword-blocking", PlayerModuleOverride.FORCE_ENABLED);
|
||||
overrides.put("disable-offhand", PlayerModuleOverride.FORCE_DISABLED);
|
||||
api.setModuleOverridesForPlayer(player, overrides);
|
||||
```
|
||||
|
||||
Kotlin:
|
||||
```kotlin
|
||||
api.setModuleOverridesForPlayer(
|
||||
player,
|
||||
mapOf(
|
||||
"attack-frequency" to PlayerModuleOverride.FORCE_ENABLED,
|
||||
"sword-blocking" to PlayerModuleOverride.FORCE_ENABLED,
|
||||
"disable-offhand" to PlayerModuleOverride.FORCE_DISABLED,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `isModuleEnabledForPlayer`
|
||||
|
||||
Returns whether a configurable module is effectively active for a player, accounting for configured rules and any per-player override.
|
||||
```java
|
||||
boolean isModuleEnabledForPlayer(Player player, String moduleName)
|
||||
```
|
||||
|
||||
**Throws:** `IllegalArgumentException` if `moduleName` is unknown or non-configurable.
|
||||
```java
|
||||
if (api.isModuleEnabledForPlayer(player, "attack-frequency")) {
|
||||
// module is active for this player
|
||||
}
|
||||
```
|
||||
|
||||
Kotlin:
|
||||
```kotlin
|
||||
if (api.isModuleEnabledForPlayer(player, "attack-frequency")) {
|
||||
// module is active for this player
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `hasAnyOverrideForPlayer`
|
||||
|
||||
Returns whether a player has any non-default override set.
|
||||
```java
|
||||
boolean hasAnyOverrideForPlayer(Player player)
|
||||
```
|
||||
```java
|
||||
if (api.hasAnyOverrideForPlayer(player)) {
|
||||
player.sendMessage("You have active overrides.");
|
||||
}
|
||||
```
|
||||
|
||||
Kotlin:
|
||||
```kotlin
|
||||
if (api.hasAnyOverrideForPlayer(player)) {
|
||||
player.sendMessage("You have active overrides.")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `getModuleNames`
|
||||
|
||||
Returns the names of all configurable modules that support per-player overrides. Only names in this set are valid inputs for the other API methods.
|
||||
```java
|
||||
Set<String> getModuleNames()
|
||||
```
|
||||
```java
|
||||
for (String name : api.getModuleNames()) {
|
||||
player.sendMessage(name);
|
||||
}
|
||||
```
|
||||
|
||||
Kotlin:
|
||||
```kotlin
|
||||
api.getModuleNames().forEach { name ->
|
||||
player.sendMessage(name)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `PlayerModuleOverride`
|
||||
|
||||
| Value | Description |
|
||||
|------------------|----------------------------------------------------------------------------------------|
|
||||
| `DEFAULT` | No per-player override; the player follows configured module and modeset rules. |
|
||||
| `FORCE_ENABLED` | Module is forced on for the player before configured disabled modules are considered. |
|
||||
| `FORCE_DISABLED` | Module is forced off for the player before configured enabled modules are considered. |
|
||||
|
||||
---
|
||||
|
||||
## Events
|
||||
|
||||
Register event listeners with Bukkit as usual. Events are fired after OCM changes the stored API state and before it reapplies module state for the affected player.
|
||||
|
||||
### `PlayerModesetChangeEvent`
|
||||
|
||||
Fired when a player's stored modeset changes. Reasons are `API`, `COMMAND`, `WORLD_CHANGE`, and `JOIN`.
|
||||
|
||||
```java
|
||||
@EventHandler
|
||||
public void onModesetChange(PlayerModesetChangeEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
String previous = event.getPreviousModeset();
|
||||
String current = event.getNewModeset();
|
||||
|
||||
player.sendMessage("Modeset changed from " + previous + " to " + current
|
||||
+ " because of " + event.getReason());
|
||||
}
|
||||
```
|
||||
|
||||
`getPreviousModeset()` and `getNewModeset()` can return `null` when no modeset was previously stored or no replacement is available.
|
||||
|
||||
### `PlayerModuleOverrideChangeEvent`
|
||||
|
||||
Fired when a player's per-player module override changes.
|
||||
|
||||
```java
|
||||
@EventHandler
|
||||
public void onModuleOverrideChange(PlayerModuleOverrideChangeEvent event) {
|
||||
if (event.getNewOverride() == PlayerModuleOverride.DEFAULT) {
|
||||
event.getPlayer().sendMessage(event.getModuleName() + " is following configured rules again.");
|
||||
return;
|
||||
}
|
||||
|
||||
event.getPlayer().sendMessage(event.getModuleName() + " changed from "
|
||||
+ event.getPreviousOverride() + " to " + event.getNewOverride());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Java example
|
||||
```java
|
||||
public class ArenaCombatManager {
|
||||
|
||||
private final OldCombatMechanicsAPI api;
|
||||
|
||||
private static final Map<String, PlayerModuleOverride> ARENA_OVERRIDES;
|
||||
static {
|
||||
ARENA_OVERRIDES = new HashMap<>();
|
||||
ARENA_OVERRIDES.put("attack-frequency", PlayerModuleOverride.FORCE_ENABLED);
|
||||
ARENA_OVERRIDES.put("sword-blocking", PlayerModuleOverride.FORCE_ENABLED);
|
||||
ARENA_OVERRIDES.put("disable-offhand", PlayerModuleOverride.FORCE_ENABLED);
|
||||
}
|
||||
|
||||
public ArenaCombatManager(OldCombatMechanicsAPI api) {
|
||||
this.api = api;
|
||||
}
|
||||
|
||||
public void onPlayerJoinArena(Player player) {
|
||||
api.setModuleOverridesForPlayer(player, ARENA_OVERRIDES);
|
||||
}
|
||||
|
||||
public void onPlayerLeaveArena(Player player) {
|
||||
api.clearAllModuleOverridesForPlayer(player);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Kotlin example
|
||||
```kotlin
|
||||
class ArenaCombatManager(private val api: OldCombatMechanicsAPI) {
|
||||
|
||||
private val arenaOverrides = mapOf(
|
||||
"attack-frequency" to PlayerModuleOverride.FORCE_ENABLED,
|
||||
"sword-blocking" to PlayerModuleOverride.FORCE_ENABLED,
|
||||
"disable-offhand" to PlayerModuleOverride.FORCE_ENABLED,
|
||||
)
|
||||
|
||||
fun onPlayerJoinArena(player: Player) {
|
||||
api.setModuleOverridesForPlayer(player, arenaOverrides)
|
||||
}
|
||||
|
||||
fun onPlayerLeaveArena(player: Player) {
|
||||
api.clearAllModuleOverridesForPlayer(player)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,162 @@
|
||||
# Changelog
|
||||
|
||||
## [2.5.0](https://github.com/kernitus/BukkitOldCombatMechanics/compare/v2.4.0...v2.5.0) (2026-05-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add per-player module override api ([53780bc](https://github.com/kernitus/BukkitOldCombatMechanics/commit/53780bc3b286dd8a0bd1e685df114326c24db96d))
|
||||
* add per-player module override api ([c2043e2](https://github.com/kernitus/BukkitOldCombatMechanics/commit/c2043e23e064b2893f8ee32c113191f6952d639f))
|
||||
* **api:** expose per-player modeset controls ([b3a6039](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b3a603991814fe6840014d9949a995cb59b22d60))
|
||||
* **api:** publish player state change events ([6f33807](https://github.com/kernitus/BukkitOldCombatMechanics/commit/6f338073663e2720b5c7a390f986246ee82545ef))
|
||||
* improve API & introduce API.md ([a091b06](https://github.com/kernitus/BukkitOldCombatMechanics/commit/a091b0688982fffc6b2fb862ca5750f4575adf38))
|
||||
* migrate default world modesets ([8e9ee90](https://github.com/kernitus/BukkitOldCombatMechanics/commit/8e9ee90cec78c34034b4bfddec1a529f732b2cab))
|
||||
* per-item cooldown ([890c41f](https://github.com/kernitus/BukkitOldCombatMechanics/commit/890c41f442ed2ba5570096856b206829c1159ea8)), closes [#856](https://github.com/kernitus/BukkitOldCombatMechanics/issues/856)
|
||||
* rewrite in kotlin ([d4eee15](https://github.com/kernitus/BukkitOldCombatMechanics/commit/d4eee15c54952a68a8c3635b497835d4fd969165))
|
||||
* show build commit in version output ([e20fe98](https://github.com/kernitus/BukkitOldCombatMechanics/commit/e20fe988e9cc3fb3f174fe50cf8e8941d465232f))
|
||||
* support default world modesets ([7d3b417](https://github.com/kernitus/BukkitOldCombatMechanics/commit/7d3b41707bb879b81e753182292a7c51a2e9ee99))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **armour:** apply mixed-mode armour strength by defender ([f32671b](https://github.com/kernitus/BukkitOldCombatMechanics/commit/f32671bc02c71da8978a5e5d33513b995ccc1ea0))
|
||||
* **armour:** respect modesets for explosion defence ([#861](https://github.com/kernitus/BukkitOldCombatMechanics/issues/861)) ([729c311](https://github.com/kernitus/BukkitOldCombatMechanics/commit/729c311e6e77df42281084e0a3a276376c834050))
|
||||
* **attack-range:** detect Paper data component support ([b12829d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b12829d7c300ad5c516bb481e3a28c8c2653d723))
|
||||
* **compat:** avoid hot-path optional API reflection ([8f14b8b](https://github.com/kernitus/BukkitOldCombatMechanics/commit/8f14b8b8e2f7d8d1e71a059e85c4bebf20f899a8))
|
||||
* **compat:** clarify semantic version gates ([b5341f3](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b5341f3898308f34b4aa8ca81ac91d8695cb6158))
|
||||
* **compat:** detect API capabilities for version gates ([bfbb2e4](https://github.com/kernitus/BukkitOldCombatMechanics/commit/bfbb2e420ffeeb6665a1c73afb6f66eb32265de7))
|
||||
* **damage:** respect bypasses_cooldown damage types [#864](https://github.com/kernitus/BukkitOldCombatMechanics/issues/864) ([83a7154](https://github.com/kernitus/BukkitOldCombatMechanics/commit/83a7154d8cea60265be6ea46acb0165d1767ce68))
|
||||
* **modesets:** correct stale stored world modesets ([#865](https://github.com/kernitus/BukkitOldCombatMechanics/issues/865)) ([b0f451d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b0f451d7de4a30e40047f4a382da77ffeef6127d))
|
||||
* **reflection:** compare Minecraft versions lexicographically ([67e266c](https://github.com/kernitus/BukkitOldCombatMechanics/commit/67e266c938dc51fef17b68ba90ef29785c69e9f9))
|
||||
* respect MythicMobs no-knockback damage ([#860](https://github.com/kernitus/BukkitOldCombatMechanics/issues/860)) ([a25e0ed](https://github.com/kernitus/BukkitOldCombatMechanics/commit/a25e0ed24c87477f6ae912230568c323d55ed576))
|
||||
* **shields:** apply mixed-mode blocking by defender ([20249db](https://github.com/kernitus/BukkitOldCombatMechanics/commit/20249db1ee46875494f96942890ed318ec7101f0))
|
||||
* **tool-damage:** apply mixed-mode weapon damage by attacker ([eb905f2](https://github.com/kernitus/BukkitOldCombatMechanics/commit/eb905f21b8eabed8ad2029d352ff99cb7978a287))
|
||||
* validate module override bulk updates ([c6e14ba](https://github.com/kernitus/BukkitOldCombatMechanics/commit/c6e14bac3dd270738572064d101ad1acd73a5636))
|
||||
|
||||
## [2.4.0](https://github.com/kernitus/BukkitOldCombatMechanics/compare/v2.3.0...v2.4.0) (2026-03-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* togglable paper sword blocking ([5d3887d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/5d3887d5b18849ec150cabbd459e66104708dda3))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't overwrite swords on every click [#843](https://github.com/kernitus/BukkitOldCombatMechanics/issues/843) ([4323853](https://github.com/kernitus/BukkitOldCombatMechanics/commit/432385320c418c0e46df6869e56af051629bfdab))
|
||||
* **inventory:** harden stale deferred item mutation paths ([a92664d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/a92664d293af0dd5fd445ca7db47e078386affd7))
|
||||
* **reflection:** tighten chooser compatibility fallback ([f383340](https://github.com/kernitus/BukkitOldCombatMechanics/commit/f3833406e4b471a3d89f5e1c5ed8ada9d1d3e1ae))
|
||||
* strip sword consumable component ([4624ac0](https://github.com/kernitus/BukkitOldCombatMechanics/commit/4624ac0c422d7f05835cd511b701026769917292))
|
||||
* **sword-blocking:** clear sword consumable components on reload when disabled ([b887a28](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b887a28a95b07efa5ec641660aee590207a0d50d)), closes [#845](https://github.com/kernitus/BukkitOldCombatMechanics/issues/845)
|
||||
* **sword-blocking:** fall back to shield for pre-1.20.5 clients ([388bee5](https://github.com/kernitus/BukkitOldCombatMechanics/commit/388bee573169a45a27c41558a946e493880677e7)), closes [#842](https://github.com/kernitus/BukkitOldCombatMechanics/issues/842)
|
||||
* **sword-blocking:** harden inventory fail-safes to prevent ghosting ([8889bfe](https://github.com/kernitus/BukkitOldCombatMechanics/commit/8889bfeb467c9f64f4d35a2d35e8456f179c35d1))
|
||||
* **sword-blocking:** harden legacy death shield drop reconciliation ([2bb730e](https://github.com/kernitus/BukkitOldCombatMechanics/commit/2bb730e5c223da5c1cd73c91a093c86d3dedefbb))
|
||||
* **sword-blocking:** prevent GUI click/drag item rewrites & fallback unknown clients to shield ([cfc596c](https://github.com/kernitus/BukkitOldCombatMechanics/commit/cfc596c3d674fd99a9c07b4c7c07c354d58fc755))
|
||||
* **sword-blocking:** prevent legacy fallback from cancelling unrelated shield interactions ([727fa97](https://github.com/kernitus/BukkitOldCombatMechanics/commit/727fa97b4e904ac06514170aae27ba1bc91dbb8b))
|
||||
* **sword-blocking:** restore offhand item for pre-1.20.5 fallback clients ([68073e8](https://github.com/kernitus/BukkitOldCombatMechanics/commit/68073e876dffa853329c414d156086fe1c76b3fd))
|
||||
* **sword-blocking:** sweep stale consumable state on join/quit/world ([96d6981](https://github.com/kernitus/BukkitOldCombatMechanics/commit/96d698145e9d8f1543b272adf87d5026076de927))
|
||||
|
||||
## [2.3.0](https://github.com/kernitus/BukkitOldCombatMechanics/compare/v2.2.0...v2.3.0) (2026-01-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* 1.8 hitbox ([5cbd93d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/5cbd93d89dde101dd7e750d4ee13e733b6dfd4a2)), closes [#69](https://github.com/kernitus/BukkitOldCombatMechanics/issues/69)
|
||||
* always & disabled modules lists ([5f7bf12](https://github.com/kernitus/BukkitOldCombatMechanics/commit/5f7bf127412e2675294d27703ac6562a4c8e0c9d))
|
||||
* always & disabled modules lists ([18dd6e1](https://github.com/kernitus/BukkitOldCombatMechanics/commit/18dd6e17aeb5e391fbb5704c0726d05e0b676971))
|
||||
* copper tools support [#822](https://github.com/kernitus/BukkitOldCombatMechanics/issues/822) [#823](https://github.com/kernitus/BukkitOldCombatMechanics/issues/823) ([47ffa96](https://github.com/kernitus/BukkitOldCombatMechanics/commit/47ffa963ecc8619f064b98054d35df81f89a682d))
|
||||
* custom trident & mace damage ([be70c5b](https://github.com/kernitus/BukkitOldCombatMechanics/commit/be70c5bb7756699fe4e3cb5bced450df3308f195)), closes [#757](https://github.com/kernitus/BukkitOldCombatMechanics/issues/757)
|
||||
* item damage lore ([0ca855a](https://github.com/kernitus/BukkitOldCombatMechanics/commit/0ca855a21634c933ab0626770a74f756e57849fe)), closes [#775](https://github.com/kernitus/BukkitOldCombatMechanics/issues/775)
|
||||
* kotlin integration tests ([c63c940](https://github.com/kernitus/BukkitOldCombatMechanics/commit/c63c940fcc292d4db3a27185a613a01e7c5c04f0))
|
||||
* switch from protocollib to packetevents ([44afce1](https://github.com/kernitus/BukkitOldCombatMechanics/commit/44afce1a0f01a0ad54b39662e3597a8e371c5454)), closes [#790](https://github.com/kernitus/BukkitOldCombatMechanics/issues/790)
|
||||
* sword blocking animation [#769](https://github.com/kernitus/BukkitOldCombatMechanics/issues/769) ([8596c9d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/8596c9da58dc2167d05ac878d4a7809014ff02d8))
|
||||
* warn on unknown effects, enchants, etc ([fa828d3](https://github.com/kernitus/BukkitOldCombatMechanics/commit/fa828d3469ca18b51ffd0871bd8e510f0c831d1c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 'disable-offhand' module working even if disabled ([ecca0b5](https://github.com/kernitus/BukkitOldCombatMechanics/commit/ecca0b56d6f8c1d9b2e96d190ae2098dfeb10fc4))
|
||||
* `disable-offhand` handling on modeset change ([54aaf0c](https://github.com/kernitus/BukkitOldCombatMechanics/commit/54aaf0c1a0a812c89c39ae0c49fe6300760a8ecc))
|
||||
* apply old tool damage to all mobs ([91b121f](https://github.com/kernitus/BukkitOldCombatMechanics/commit/91b121ff009971586c877778e6a75309088ba667)), closes [#735](https://github.com/kernitus/BukkitOldCombatMechanics/issues/735)
|
||||
* chorus fruit tp into blocks ([bba1ecb](https://github.com/kernitus/BukkitOldCombatMechanics/commit/bba1ecb62ec9faf1526189f308798d3b25f43cf9)), closes [#748](https://github.com/kernitus/BukkitOldCombatMechanics/issues/748)
|
||||
* clear modules list on reload ([ebdfd10](https://github.com/kernitus/BukkitOldCombatMechanics/commit/ebdfd101ae1393dbf97c431726b09ea797cc267d))
|
||||
* double strength effect when old-potion-effects disabled ([4cecb64](https://github.com/kernitus/BukkitOldCombatMechanics/commit/4cecb649c8e03c0c2fd593b907da778e3f7dc453)), closes [#781](https://github.com/kernitus/BukkitOldCombatMechanics/issues/781)
|
||||
* fire damage overwriting lastDamage ([9af8a0f](https://github.com/kernitus/BukkitOldCombatMechanics/commit/9af8a0fa796513ab88588612f5551c5bf582db32)), closes [#707](https://github.com/kernitus/BukkitOldCombatMechanics/issues/707)
|
||||
* legacy (pre-1.11) sweep detection ([2825b35](https://github.com/kernitus/BukkitOldCombatMechanics/commit/2825b35b4c6b0b879389da170e6d85b9441d9799))
|
||||
* negative last damage ([1321717](https://github.com/kernitus/BukkitOldCombatMechanics/commit/1321717288ec9624065d86b00aa441f9a1404b52)), closes [#765](https://github.com/kernitus/BukkitOldCombatMechanics/issues/765)
|
||||
* only strip consumable on swords ([e01faea](https://github.com/kernitus/BukkitOldCombatMechanics/commit/e01faea183d8387371dbd62e89fb847deb2fc38e)), closes [#841](https://github.com/kernitus/BukkitOldCombatMechanics/issues/841)
|
||||
* reflection error on weapon enchant ([10ff4ce](https://github.com/kernitus/BukkitOldCombatMechanics/commit/10ff4ceead5147b87c13ffea1401ef716596b80c)), closes [#840](https://github.com/kernitus/BukkitOldCombatMechanics/issues/840)
|
||||
* skip unknown sound packets ([cfa1e58](https://github.com/kernitus/BukkitOldCombatMechanics/commit/cfa1e58b5b5e481e1427458d22a98e487b32a621))
|
||||
* unknown particles. ([1408720](https://github.com/kernitus/BukkitOldCombatMechanics/commit/140872008929ecc49918bdf0cc8e40d226957054)), closes [#825](https://github.com/kernitus/BukkitOldCombatMechanics/issues/825)
|
||||
* weakness calc on >=1.20 ([f502adb](https://github.com/kernitus/BukkitOldCombatMechanics/commit/f502adbbece03875e491bd1f39e4a45d1f498802))
|
||||
* weakness calculations for amplifier > 1 ([d866015](https://github.com/kernitus/BukkitOldCombatMechanics/commit/d86601504eb6d16481e18946cf07e7432039cd92))
|
||||
|
||||
|
||||
### Performance
|
||||
|
||||
* **attack-cooldown-tracker:** gate sampler by API presence and use HashMap for stable caching ([b605501](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b605501b40d350457d5227ce24c9c9a9522ed1f9))
|
||||
* **disable-enderpearl-cooldown:** use HashMap and lazily drop expired cooldown entries ([83ff726](https://github.com/kernitus/BukkitOldCombatMechanics/commit/83ff726c5a9a2c2e6cc16571ce6e2d37123341ab))
|
||||
* **fishing-rod-velocity:** replace per-hook gravity tasks with single shared tick runner ([5856bed](https://github.com/kernitus/BukkitOldCombatMechanics/commit/5856bed51830c51b0b6c205cb010fcb8dd9be0ac))
|
||||
* **old-armour-durability:** replace per-explosion suppression task with shared 1-tick expiry cleaner ([c7932d6](https://github.com/kernitus/BukkitOldCombatMechanics/commit/c7932d6f0b32966ad38cdb81f8c83b14ca438478))
|
||||
* **old-player-regen:** switch to tick-based interval tracking with shared counter task ([4f8df3c](https://github.com/kernitus/BukkitOldCombatMechanics/commit/4f8df3c5d0781240a46a4ed7e1ce10043aafb8d1))
|
||||
* **player-knockback:** replace per-hit cleanup tasks with shared 1-tick expiry cleaner ([9667ef5](https://github.com/kernitus/BukkitOldCombatMechanics/commit/9667ef539e033ede2b97fadf309cd805df6900c5))
|
||||
* **shield-damage-reduction:** replace per-hit fully-blocked cleanup tasks with shared 1-tick expiry cleaner ([a18b1ef](https://github.com/kernitus/BukkitOldCombatMechanics/commit/a18b1efec039c35b12f2c7c53dcc2be875545b29))
|
||||
* **sword-block:** reduce amount of recurring tasks ([a08b5b4](https://github.com/kernitus/BukkitOldCombatMechanics/commit/a08b5b47bc5c1645db885ffca794e3ac7d9592ba))
|
||||
* use one task to clear EDBEE map ([11ec51b](https://github.com/kernitus/BukkitOldCombatMechanics/commit/11ec51bd609339b8e41d74ef2d8697c3083a7d5f))
|
||||
|
||||
## [2.2.0](https://github.com/kernitus/BukkitOldCombatMechanics/compare/v2.1.0...v2.2.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add fallback sound reflection logic ([1592dc2](https://github.com/kernitus/BukkitOldCombatMechanics/commit/1592dc249870ad3113b924cdebd41cbc36b68ad5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ocm mode permissions ([e2f0369](https://github.com/kernitus/BukkitOldCombatMechanics/commit/e2f0369f294e250e8cfc474bbd1121498ecf09fe)), closes [#818](https://github.com/kernitus/BukkitOldCombatMechanics/issues/818)
|
||||
* update checker versioning logic ([8d88a49](https://github.com/kernitus/BukkitOldCombatMechanics/commit/8d88a49d0fa1e8c7dfa7c48cf66c8399c766d6e0))
|
||||
* use reflection for inventory view ([462e536](https://github.com/kernitus/BukkitOldCombatMechanics/commit/462e536628f3df544c9cf0fe42705eff46b7d4f6)), closes [#812](https://github.com/kernitus/BukkitOldCombatMechanics/issues/812)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* update issue templates ([5927729](https://github.com/kernitus/BukkitOldCombatMechanics/commit/5927729ea5fcca44a6218b10f40cec3f8ce3d4a3))
|
||||
* update issue templates ([09fd3c5](https://github.com/kernitus/BukkitOldCombatMechanics/commit/09fd3c556ad02d25caa875e7dfe5854888a920cf))
|
||||
* use yaml issue forms ([fc51924](https://github.com/kernitus/BukkitOldCombatMechanics/commit/fc51924504e3718b9829f4a7deca7a8701000f9f))
|
||||
|
||||
## [2.1.0](https://github.com/kernitus/BukkitOldCombatMechanics/compare/v2.0.4...v2.1.0) (2025-08-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* compat with 1.21.8 enums ([68c51ab](https://github.com/kernitus/BukkitOldCombatMechanics/commit/68c51ab8803da56f477660af247c37e5171bc581))
|
||||
* make config upgrader remove deprecated keys ([e728374](https://github.com/kernitus/BukkitOldCombatMechanics/commit/e72837462fb8c512c9971c1e7d9376c82f37e741))
|
||||
* remove deprecated modules ([da3d5f0](https://github.com/kernitus/BukkitOldCombatMechanics/commit/da3d5f0b28990d8f654b8557e68f54f41e8b5a60))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* check fishing knockback module enabled when reeling in ([31e3877](https://github.com/kernitus/BukkitOldCombatMechanics/commit/31e3877330cdd820173a5d89e021919b153c6988)), closes [#803](https://github.com/kernitus/BukkitOldCombatMechanics/issues/803)
|
||||
* disable attack sounds not working in >1.21 ([b355322](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b355322f9b3f5e1c2b1e889684d6242f08ceee92)), closes [#794](https://github.com/kernitus/BukkitOldCombatMechanics/issues/794)
|
||||
* ExceptionInInitialiserError in enchantment compat ([b2379cc](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b2379cc17e309b035c2acb396392723f15ed3ee2)), closes [#782](https://github.com/kernitus/BukkitOldCombatMechanics/issues/782)
|
||||
* improve sound packet compatibility ([4ef28fb](https://github.com/kernitus/BukkitOldCombatMechanics/commit/4ef28fb7bd63631fa4b6f0366d23dd1e159aa115)), closes [#780](https://github.com/kernitus/BukkitOldCombatMechanics/issues/780)
|
||||
* null pointer in potion compat ([b30e27d](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b30e27ddb32d210371152feee8d337f27ea8f495)), closes [#791](https://github.com/kernitus/BukkitOldCombatMechanics/issues/791)
|
||||
* unmentioned worlds modesets not allowed [#792](https://github.com/kernitus/BukkitOldCombatMechanics/issues/792) ([95c9446](https://github.com/kernitus/BukkitOldCombatMechanics/commit/95c9446edbd0fe56bce0864798cf8d1c70865f8b))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* improve fishing knockback cross-version compat ([d119c9f](https://github.com/kernitus/BukkitOldCombatMechanics/commit/d119c9f35e1a89be8fb8d03573041cfc2ae2d418))
|
||||
|
||||
## [2.0.4](https://github.com/kernitus/BukkitOldCombatMechanics/compare/2.0.3...v2.0.4) (2024-10-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* avoid ghost items after sword block restore ([822fb1f](https://github.com/kernitus/BukkitOldCombatMechanics/commit/822fb1fa147fc49266cb9f0668869959e341982e)), closes [#749](https://github.com/kernitus/BukkitOldCombatMechanics/issues/749)
|
||||
* don't prevent moving shield to chests in disable offhand module ([b299df2](https://github.com/kernitus/BukkitOldCombatMechanics/commit/b299df2d21ace1c7e88b1ee8fafb297e2a9347e8))
|
||||
* error when right clicking air while holding block in <1.13 ([cbc0c4b](https://github.com/kernitus/BukkitOldCombatMechanics/commit/cbc0c4bc8bf0afd56005699ce70f86ec9b637646)), closes [#754](https://github.com/kernitus/BukkitOldCombatMechanics/issues/754)
|
||||
* listen to dynamically loaded worlds for modesets ([f5b59d7](https://github.com/kernitus/BukkitOldCombatMechanics/commit/f5b59d7537d410fac35fbb4e0181a61a485ae1a5)), closes [#747](https://github.com/kernitus/BukkitOldCombatMechanics/issues/747)
|
||||
* resolve elytras always unequipped by removing out-of-scope module ([07106e6](https://github.com/kernitus/BukkitOldCombatMechanics/commit/07106e61a220ec4137a3de200a393cf6aaa50be7)), closes [#725](https://github.com/kernitus/BukkitOldCombatMechanics/issues/725)
|
||||
* fix sword blocking shield ending up in inventory on world change ([8aa3fa3](https://github.com/kernitus/BukkitOldCombatMechanics/commit/8aa3fa33081c1e1b1a48baa484fd6946b275362b)), closes [#753](https://github.com/kernitus/BukkitOldCombatMechanics/issues/753)
|
||||
@@ -0,0 +1,373 @@
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
@@ -1,60 +1,131 @@
|
||||
<!--
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<p align="center">
|
||||
<img src="res/ocm-icon.png" width=320>
|
||||
<img src="res/ocm-banner.png" width=1000>
|
||||
</p>
|
||||
|
||||
## by kernitus and Rayzr522
|
||||
|
||||
Fine‑tune Minecraft combat, movement, and item balance without breaking your server. OldCombatMechanics is a free, open‑source toolkit for Spigot & Paper that lets you mix 1.8‑style snappiness with modern features, per world and per player. It is built for PvP networks, minigames, BedWars/SkyWars-style games, duels, KitPvP, practice arenas, and mixed PvP/PvE worlds that need different combat rules in different places.
|
||||
|
||||
**Why servers pick OCM** ✨
|
||||
- 🧩 **Modular:** enable only what you need: cooldowns, tool damage, knockback, shields, potions, reach, sounds, more.
|
||||
- 🚀 **Performant:** lean listeners only enabled as needed; reflection lookups are cached and recurring tasks are minimised (shared where possible) to keep tick time low on busy PvP servers.
|
||||
- 🗺️ **Modesets:** ship different rules for different worlds or players; perfect for PvP networks, BedWars/SkyWars-style minigames, duels, KitPvP, FFA, practice arenas, and mixed PvP/PvE.
|
||||
- 🔌 **Plugin-friendly API:** integrate OCM with game logic by applying session-only per-player module overrides, managing player modesets, and checking whether modules are effectively enabled.
|
||||
- ⏪ **Backwards‑friendly:** runs on Java 8+, supports 1.9 to latest; integrates cleanly with PlaceholderAPI and PacketEvents.
|
||||
- ✅ **Tested for you:** live integration tests run real Paper servers across multiple versions every build.
|
||||
- 💸 **Zero cost:** fully open source, optional basic telemetry (bStats only), no paywalls.
|
||||
|
||||
**Quick start** ⚡
|
||||
1. Drop the jar into `plugins/` (Spigot or Paper-derivatives 1.9+).
|
||||
2. Restart and edit `config.yml` to pick your modules and modesets.
|
||||
3. Use `/ocm reload` to apply changes instantly.
|
||||
4. Hand players `/ocm modeset <name>` to let them choose their ruleset.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hangar.papermc.io/kernitus/OldCombatMechanics">
|
||||
<img src="res/paper.png" alt="Paper" height="100">
|
||||
</a>
|
||||
<a href="https://www.spigotmc.org/resources/19510/">
|
||||
<img src="res/spigot.png" alt="Spigot" height="100">
|
||||
</a>
|
||||
<a href="https://dev.bukkit.org/projects/oldcombatmechanics">
|
||||
<img src="res/bukkit.png" alt="Bukkit" height="100">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<hr/>
|
||||
|
||||
# Old Combat Mechanics
|
||||
#### by kernitus and Rayzr522
|
||||
A Bukkit/Spigot plugin to revert combat mechanics to pre-1.9. Also supports reverting other features.
|
||||
## 🧰 Modesets
|
||||
- Per-player/per-world presets that decide which features are active; each world has an allowed list and a default modeset.
|
||||
- Let players pick ( `/ocm modeset <name>` ) to run, for example, 1.8-style PvP in an arena world while keeping vanilla rules in survival.
|
||||
|
||||
## 🏟 Built for minigames and PvP networks
|
||||
- Run old-style combat in competitive arenas while keeping modern mechanics in lobbies, survival, or event worlds.
|
||||
- Tailor rules for BedWars, SkyWars, duels, KitPvP, FFA, practice arenas, tournaments, and temporary events without splitting your network across extra plugins.
|
||||
- Keep configuration readable for staff: combine world defaults, player-selected modesets, and targeted plugin overrides for special match phases.
|
||||
|
||||
## 🔌 Developer API
|
||||
- OCM registers its API through Bukkit's services manager, so other plugins can look it up without depending on internal implementation classes.
|
||||
- The current API supports session-only per-player module overrides, player modeset queries/changes, and events for reacting to OCM state changes.
|
||||
- Plugins can also check whether a module is effectively enabled for a player after world rules, player choices, and overrides have been considered.
|
||||
- Overrides are not persisted across restarts or reconnects; reapply them from your game state when needed.
|
||||
- See [API.md](API.md) for dependency setup, `plugin.yml` guidance, method details, and Java/Kotlin examples.
|
||||
|
||||
## ⚙ Configurable Features
|
||||
Features are grouped in `module`s as listed below, and can be individually configured and disabled. Disabled modules will have no impact on server performance.
|
||||
|
||||
#### ⚔ Combat
|
||||
*Tweak timing, damage, and reach.*
|
||||
- **Attack cooldown:** adjust or remove 1.9+ cooldown
|
||||
- **Attack frequency:** set global hit delay
|
||||
- **Tool damage:** pre-1.9 weapon values
|
||||
- **Attack range (Paper 1.21.11+):** 1.8-style reach
|
||||
- **Critical hits:** control crit multiplier
|
||||
- **Player regen:** tune regen rates
|
||||
|
||||
#### 🤺 Armour
|
||||
*Balance defence and wear.*
|
||||
- **Armour strength:** scale armour protection
|
||||
- **Armour durability:** change durability loss
|
||||
|
||||
#### 🛡 Swords & Shields
|
||||
*Control block and sweep behaviour.*
|
||||
- **Sword blocking:** restore old right-click block; on Paper 1.21.2+ we also add the native sword blocking animation via the consumable component
|
||||
- **Shield damage reduction:** scale shield protection
|
||||
- **Sword sweep:** enable or disable sweeps
|
||||
- **Sword sweep particles:** hide or show sweep visuals
|
||||
|
||||
#### 🌬 Knockback
|
||||
*Shape knockback per source.*
|
||||
- **Player knockback:** adjust PvP knockback
|
||||
- **Fishing knockback:** fishing-rod knockback
|
||||
- **Fishing rod velocity:** pull speed
|
||||
- **Projectile knockback:** arrows and other projectiles
|
||||
|
||||
#### 🧙 Gapples & Potions
|
||||
*Change consumable power.*
|
||||
- **Golden apple crafting and effects:** notch and normal
|
||||
- **Potion effects and duration:** old-style values
|
||||
- **Chorus fruit:** teleport behaviour and range
|
||||
|
||||
#### ❌ New feature disabling
|
||||
*Toggle later-version mechanics.*
|
||||
- **Item crafting:** block selected recipes
|
||||
- **Offhand:** disable offhand use
|
||||
- **New attack sounds:** mute new swing sounds
|
||||
- **Enderpearl cooldown:** enable or remove cooldown
|
||||
- **Brewing stand refuel:** alter fuel use
|
||||
- **Burn delay:** adjust fire tick delay
|
||||
|
||||
## 🔌 Compatibility & Testing
|
||||
- OCM targets Spigot 1.9+ and runs on Java 8 and up.
|
||||
- We stick to Spigot/Paper APIs for forward compatibility; NMS/reflection is used only when necessary.
|
||||
- Integration tests boot real servers on 1.9.4, 1.12, 1.19.2, 1.21.11, and 26.1.2 each build to verify behaviour.
|
||||
- Most plugins work fine with OCM. Targeted compatibility includes PlaceholderAPI (see [wiki](https://github.com/kernitus/BukkitOldCombatMechanics/wiki/PlaceholderAPI)) and MythicMobs no-knockback damage skills with old-player-knockback.
|
||||
|
||||
## 🧾 Licence
|
||||
- Source code in this repository is under the Mozilla Public License 2.0 (MPL‑2.0).
|
||||
- Pre-built jars bundle PacketEvents (GPLv3). Those binary distributions are provided under GPLv3 terms due to the included dependency.
|
||||
- If you build a jar without PacketEvents, you may distribute that build under MPL‑2.0, subject to its terms.
|
||||
|
||||
## ⚡ Development Builds
|
||||
Oftentimes a particular bug fix or feature has already been implemented, but a new version of OCM has not been released
|
||||
yet. You can find the most up-to-date version of the plugin
|
||||
on [Hangar](https://hangar.papermc.io/kernitus/OldCombatMechanics/versions?channel=Snapshot&platform=PAPER).
|
||||
|
||||
|
||||
## 🤝 Contributions
|
||||
If you are interested in contributing, please [check this page first](.github/CONTRIBUTING.md).
|
||||
|
||||
## Features
|
||||
- Master & per-world toggle for each module
|
||||
- All modules fully customisable
|
||||
- Remove weapon cooldown
|
||||
- Setting tool damage as in pre-1.9
|
||||
- Make players regenerate like in pre-1.9 & with correct exhaustion
|
||||
- Give players a shield while they right click with a sword
|
||||
- Have armour be as strong as in pre-1.9
|
||||
- Have configurable old potion durations
|
||||
- Have configurable old potion effects (strength & weakness)
|
||||
- Disable sword sweep attack
|
||||
- Option to disable shield crafting
|
||||
- Disable off-hand (with white/blacklist)
|
||||
- Make shields block 1/2 a heart instead of reduce damage to 33%
|
||||
- Configurable general & projectile damage reduction for shields
|
||||
- Have arrows not be stopped by shields
|
||||
- Re-add snowball, egg & enderpearl knockback
|
||||
- Disable player collisions
|
||||
- Enchanted golden apple crafting
|
||||
- Customisable enchanted & golden apple potion effects
|
||||
- Fishing rods knockback and damage like in pre-1.9 (customisable)
|
||||
- Automatically add blaze powder to brewing stands
|
||||
- Automatically add lapis in enchanting tables
|
||||
- Stop usage of elytra
|
||||
- Disable random projectile trajectories
|
||||
- Disable bow boosting
|
||||
|
||||
## Installation
|
||||
To install OldCombatMechanics, please download the latest version from the [releases page](https://github.com/kernitus/BukkitOldCombatMechanics/releases) (or from [Bukkit](http://dev.bukkit.org/bukkit-plugins/oldcombatmechanics/) or [Spigot](https://www.spigotmc.org/resources/oldcombatmechanics.19510/)). Simply drop the JAR file in your plugins folder and restart your server.
|
||||
|
||||
Development versions are available on the [OldCombatMechanics Jenkins server](https://jenkinsgvl-jenkins-gvl.1d35.starter-us-east-1.openshiftapps.com/job/OldCombatMechanics).
|
||||
|
||||
<hr/>
|
||||
|
||||
<a href="https://www.spigotmc.org/resources/oldcombatmechanics.19510/">
|
||||
<img src="https://media-elerium.cursecdn.com/attachments/202/435/spigot.png" alt="Spigot" height="100">
|
||||
</a>
|
||||
<a href="https://dev.bukkit.org/projects/oldcombatmechanics">
|
||||
<img src="https://media-elerium.cursecdn.com/attachments/202/436/bukkit.png" alt="Bukkit" height="100">
|
||||
</a>
|
||||
<a href="https://ci.rayzr.dev/job/OldCombatMechanics/">
|
||||
<img src="https://media-elerium.cursecdn.com/attachments/202/434/jenkins.png" alt="Jenkins" height="100">
|
||||
</a>
|
||||
|
||||
<hr/>
|
||||
|
||||

|
||||
<a href="https://bstats.org/plugin/bukkit/OldCombatMechanics">
|
||||
<img src="https://bstats.org/signatures/bukkit/OldCombatMechanics.svg" alt="bStats">
|
||||
</a>
|
||||
@@ -1,24 +0,0 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognise copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <http://unlicense.org>
|
||||
@@ -0,0 +1,924 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||
import groovy.json.JsonSlurper
|
||||
import io.papermc.hangarpublishplugin.model.Platforms
|
||||
import org.gradle.api.Action
|
||||
import org.gradle.api.attributes.java.TargetJvmVersion
|
||||
import org.gradle.api.file.FileCopyDetails
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
import xyz.jpenilla.runpaper.task.RunServer
|
||||
import java.io.Closeable
|
||||
import java.io.Serializable
|
||||
import java.net.URI
|
||||
import java.nio.file.Files
|
||||
import java.security.MessageDigest
|
||||
|
||||
val paperVersion: List<String> =
|
||||
(property("gameVersions") as String)
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
|
||||
plugins {
|
||||
`java-library`
|
||||
kotlin("jvm") version "2.3.21"
|
||||
id("com.diffplug.spotless") version "8.5.1"
|
||||
id("com.gradleup.shadow") version "9.4.1"
|
||||
id("xyz.jpenilla.run-paper") version "3.0.2"
|
||||
eclipse
|
||||
idea
|
||||
id("io.papermc.hangar-publish-plugin") version "0.1.4"
|
||||
}
|
||||
|
||||
spotless {
|
||||
// Avoid a noisy first formatter rollout; enforce Spotless on changed files.
|
||||
ratchetFrom("origin/master")
|
||||
|
||||
// Plugin package follows Java convention `kernitus.plugin.OldCombatMechanics.*` — renaming would break
|
||||
// every consumer. Disable ktlint's lowercase-only package rule plugin-wide.
|
||||
val ktlintOverrides =
|
||||
mapOf(
|
||||
"ktlint_standard_package-name" to "disabled",
|
||||
"ktlint_standard_filename" to "disabled"
|
||||
)
|
||||
|
||||
kotlin {
|
||||
target("src/**/*.kt")
|
||||
ktlint().editorConfigOverride(ktlintOverrides)
|
||||
}
|
||||
|
||||
kotlinGradle {
|
||||
target("*.gradle.kts", "gradle/**/*.gradle.kts")
|
||||
ktlint().editorConfigOverride(ktlintOverrides)
|
||||
endWithNewline()
|
||||
}
|
||||
|
||||
format("misc") {
|
||||
target("*.md", "*.yml", "*.yaml", "*.properties", ".editorconfig")
|
||||
trimTrailingWhitespace()
|
||||
endWithNewline()
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-apply formatters before compile so cosmetic issues self-heal instead of failing the build.
|
||||
tasks.named("compileKotlin") {
|
||||
dependsOn("spotlessApply")
|
||||
}
|
||||
tasks.named("compileJava") {
|
||||
dependsOn("spotlessApply")
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn("spotlessCheck")
|
||||
}
|
||||
|
||||
// Make sure javadocs are available to IDE
|
||||
idea {
|
||||
module {
|
||||
isDownloadJavadoc = true
|
||||
isDownloadSources = true
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven("https://repo.papermc.io/repository/maven-public/")
|
||||
// Spigot API
|
||||
maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/")
|
||||
maven("https://oss.sonatype.org/content/repositories/snapshots")
|
||||
maven("https://oss.sonatype.org/content/repositories/central")
|
||||
// PacketEvents
|
||||
maven("https://repo.codemc.io/repository/maven-releases/")
|
||||
maven("https://repo.codemc.io/repository/maven-snapshots/")
|
||||
// Placeholder API
|
||||
maven("https://repo.extendedclip.com/content/repositories/placeholderapi/")
|
||||
// CodeMC Repo for bStats
|
||||
maven("https://repo.codemc.org/repository/maven-public/")
|
||||
// Auth library from Minecraft
|
||||
maven("https://libraries.minecraft.net/")
|
||||
}
|
||||
|
||||
group = "kernitus.plugin.OldCombatMechanics"
|
||||
version = "2.5.0" // x-release-please-version
|
||||
description = "OldCombatMechanics"
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
// We can build with Java 17 but still support MC >=1.9
|
||||
// This is because MC >=1.9 server can be run with higher Java versions
|
||||
languageVersion.set(JavaLanguageVersion.of(17))
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
val integrationTest by creating {
|
||||
kotlin.setSrcDirs(listOf("src/integrationTest/kotlin"))
|
||||
resources.setSrcDirs(listOf("src/integrationTest/resources"))
|
||||
compileClasspath += main.get().output
|
||||
runtimeClasspath += output + main.get().output
|
||||
}
|
||||
val apiSmokeTest by creating {
|
||||
java.setSrcDirs(listOf("src/apiSmokeTest/java"))
|
||||
resources.setSrcDirs(listOf("src/apiSmokeTest/resources"))
|
||||
compileClasspath += main.get().output
|
||||
runtimeClasspath += output + main.get().output
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
val integrationTestImplementation by getting {
|
||||
extendsFrom(configurations.implementation.get())
|
||||
}
|
||||
create("integrationTestServerPlugins") {
|
||||
isCanBeConsumed = false
|
||||
isCanBeResolved = true
|
||||
}
|
||||
}
|
||||
|
||||
configurations.named("compileClasspath") {
|
||||
attributes.attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 17)
|
||||
}
|
||||
configurations.named("integrationTestCompileClasspath") {
|
||||
attributes.attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 17)
|
||||
}
|
||||
configurations.named("apiSmokeTestCompileClasspath") {
|
||||
attributes.attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 17)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.bstats:bstats-bukkit:3.2.1")
|
||||
// Server-provided: intentionally pinned to the oldest supported runtime baseline
|
||||
// (Minecraft 1.9.4 / Netty 4.0.23.Final) so compile-only code cannot use newer Netty APIs unavailable on legacy servers.
|
||||
compileOnly("io.netty:netty-all:4.0.23.Final")
|
||||
// Placeholder API
|
||||
compileOnly("me.clip:placeholderapi:2.12.2")
|
||||
// For BSON file serialisation
|
||||
implementation("org.mongodb:bson:5.7.0")
|
||||
// Spigot
|
||||
compileOnly("org.spigotmc:spigot-api:26.1.2-R0.1-SNAPSHOT")
|
||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:5.9.1")
|
||||
testImplementation("io.kotest:kotest-assertions-core-jvm:5.9.1")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
// JSR-305 annotations (javax.annotation.Nullable)
|
||||
compileOnly("com.google.code.findbugs:jsr305:3.0.2")
|
||||
// PacketEvents
|
||||
implementation("com.github.retrooper:packetevents-spigot:2.11.2")
|
||||
// XSeries
|
||||
implementation("com.github.cryptomorin:XSeries:13.7.0")
|
||||
|
||||
// For ingametesting
|
||||
// Mojang mappings for NMS
|
||||
|
||||
/*
|
||||
compileOnly("com.mojang:authlib:6.0.54")
|
||||
paperweight.paperDevBundle("1.19.2-R0.1-SNAPSHOT")
|
||||
// For reflection remapping
|
||||
implementation("xyz.jpenilla:reflection-remapper:0.1.3")
|
||||
*/
|
||||
|
||||
// Integration test dependencies
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.21")
|
||||
add("integrationTestImplementation", "org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.3.21")
|
||||
add("integrationTestImplementation", "org.jetbrains.kotlin:kotlin-test:2.3.21")
|
||||
add("integrationTestImplementation", "org.jetbrains.kotlin:kotlin-reflect:2.3.21")
|
||||
add("integrationTestImplementation", "io.kotest:kotest-runner-junit5-jvm:5.9.1")
|
||||
add("integrationTestImplementation", "io.kotest:kotest-assertions-core-jvm:5.9.1")
|
||||
add("integrationTestImplementation", "xyz.jpenilla:reflection-remapper:0.1.3")
|
||||
add("integrationTestCompileOnly", "org.spigotmc:spigot-api:26.1.2-R0.1-SNAPSHOT")
|
||||
add("integrationTestCompileOnly", "com.mojang:authlib:6.0.54")
|
||||
add("integrationTestCompileOnly", "io.netty:netty-all:4.0.23.Final")
|
||||
|
||||
// Java-only API smoke plugin dependencies
|
||||
add("apiSmokeTestCompileOnly", sourceSets.main.get().output)
|
||||
add("apiSmokeTestCompileOnly", "org.spigotmc:spigot-api:1.21.11-R0.1-SNAPSHOT")
|
||||
}
|
||||
|
||||
// Substitute ${pluginVersion} in plugin.yml with version defined above
|
||||
class ExpandPluginVersionAction(private val version: String) :
|
||||
Action<FileCopyDetails>,
|
||||
Serializable {
|
||||
override fun execute(details: FileCopyDetails) {
|
||||
details.expand(mapOf("pluginVersion" to version))
|
||||
}
|
||||
}
|
||||
|
||||
class ExpandBuildCommitAction(private val commit: String) :
|
||||
Action<FileCopyDetails>,
|
||||
Serializable {
|
||||
override fun execute(details: FileCopyDetails) {
|
||||
details.expand(mapOf("buildCommit" to commit))
|
||||
}
|
||||
}
|
||||
|
||||
val pluginVersion = project.version.toString()
|
||||
val buildCommit =
|
||||
runCatching {
|
||||
val process =
|
||||
ProcessBuilder("git", "rev-parse", "--short", "HEAD")
|
||||
.directory(rootDir)
|
||||
.redirectError(ProcessBuilder.Redirect.DISCARD)
|
||||
.start()
|
||||
val output = process.inputStream.bufferedReader().use { it.readText() }
|
||||
val exitValue = process.waitFor()
|
||||
|
||||
if (exitValue == 0) {
|
||||
output.trim()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}.getOrDefault("")
|
||||
val expandPluginVersionAction = ExpandPluginVersionAction(pluginVersion)
|
||||
val expandBuildCommitAction = ExpandBuildCommitAction(buildCommit)
|
||||
tasks.named<Copy>("processResources") {
|
||||
inputs.property("pluginVersion", pluginVersion)
|
||||
inputs.property("buildCommit", buildCommit)
|
||||
filesMatching("plugin.yml", expandPluginVersionAction)
|
||||
filesMatching("ocm-build.properties", expandBuildCommitAction)
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile> {
|
||||
options.encoding = "UTF-8"
|
||||
options.release.set(8)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
tasks.jar {
|
||||
// The runtime plugin is the shaded jar below; do not emit an unbundled plugin artefact.
|
||||
enabled = false
|
||||
}
|
||||
|
||||
val shadowJarTask =
|
||||
tasks.named<ShadowJar>("shadowJar") {
|
||||
dependsOn("jar")
|
||||
archiveFileName.set("${project.name}.jar")
|
||||
dependencies {
|
||||
relocate("org.bstats", "kernitus.plugin.OldCombatMechanics.lib.bstats")
|
||||
relocate("com.cryptomorin.xseries", "kernitus.plugin.OldCombatMechanics.lib.xseries")
|
||||
relocate("com.github.retrooper.packetevents", "kernitus.plugin.OldCombatMechanics.lib.packetevents.api")
|
||||
relocate("io.github.retrooper.packetevents", "kernitus.plugin.OldCombatMechanics.lib.packetevents.impl")
|
||||
}
|
||||
}
|
||||
|
||||
// For ingametesting
|
||||
|
||||
/*
|
||||
tasks.reobfJar {
|
||||
outputJar.set(File(buildDir, "libs/${project.name}.jar"))
|
||||
}
|
||||
*/
|
||||
|
||||
tasks.assemble {
|
||||
// For ingametesting
|
||||
// dependsOn("reobfJar")
|
||||
dependsOn("shadowJar")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
compilerOptions.jvmTarget.set(JvmTarget.JVM_1_8)
|
||||
}
|
||||
|
||||
val relocateIntegrationTestClasses =
|
||||
tasks.register<ShadowJar>("relocateIntegrationTestClasses") {
|
||||
archiveClassifier.set("tests-relocated")
|
||||
dependsOn("compileIntegrationTestKotlin")
|
||||
configurations = emptyList()
|
||||
from(sourceSets["integrationTest"].output)
|
||||
relocate("com.github.retrooper.packetevents", "kernitus.plugin.OldCombatMechanics.lib.packetevents.api")
|
||||
relocate("io.github.retrooper.packetevents", "kernitus.plugin.OldCombatMechanics.lib.packetevents.impl")
|
||||
}
|
||||
|
||||
val integrationTestJarTask =
|
||||
tasks.register<Jar>("integrationTestJar") {
|
||||
archiveClassifier.set("tests")
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
|
||||
dependsOn(relocateIntegrationTestClasses)
|
||||
|
||||
from(relocateIntegrationTestClasses.flatMap { it.archiveFile }.map { zipTree(it.asFile) })
|
||||
|
||||
project.configurations["integrationTestRuntimeClasspath"].forEach { file: File ->
|
||||
if (file.name.contains("packetevents", ignoreCase = true)) {
|
||||
return@forEach
|
||||
}
|
||||
from(if (file.isDirectory) file else zipTree(file))
|
||||
}
|
||||
|
||||
exclude("META-INF/*.SF")
|
||||
exclude("META-INF/*.DSA")
|
||||
exclude("META-INF/*.RSA")
|
||||
exclude("META-INF/*.EC")
|
||||
exclude("META-INF/*.MF")
|
||||
exclude("module-info.class")
|
||||
exclude("META-INF/versions/**/module-info.class")
|
||||
}
|
||||
|
||||
val apiSmokeTestJarTask =
|
||||
tasks.register<Jar>("apiSmokeTestJar") {
|
||||
archiveClassifier.set("api-smoke-test")
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
|
||||
dependsOn("apiSmokeTestClasses")
|
||||
|
||||
from(sourceSets["apiSmokeTest"].output)
|
||||
|
||||
exclude("META-INF/*.SF")
|
||||
exclude("META-INF/*.DSA")
|
||||
exclude("META-INF/*.RSA")
|
||||
exclude("META-INF/*.EC")
|
||||
exclude("META-INF/*.MF")
|
||||
exclude("module-info.class")
|
||||
exclude("META-INF/versions/**/module-info.class")
|
||||
}
|
||||
|
||||
val integrationTestMinecraftVersion =
|
||||
(findProperty("integrationTestMinecraftVersion") as String?) ?: "1.19.2"
|
||||
|
||||
val defaultIntegrationTestVersions =
|
||||
listOf(integrationTestMinecraftVersion, "26.1.2", "1.21.11", "1.12", "1.9.4")
|
||||
.distinct()
|
||||
|
||||
val integrationTestVersions: List<String> =
|
||||
(findProperty("integrationTestVersions") as String?)
|
||||
?.split(",")
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.ifEmpty { defaultIntegrationTestVersions }
|
||||
?: defaultIntegrationTestVersions
|
||||
|
||||
val integrationTestJavaVersionLegacy =
|
||||
(findProperty("integrationTestJavaVersionLegacy") as String?)?.toInt() ?: 17
|
||||
val integrationTestJavaVersionLegacyPre13 =
|
||||
(findProperty("integrationTestJavaVersionLegacyPre13") as String?)?.toInt() ?: 8
|
||||
val integrationTestJavaVersionLegacy16 =
|
||||
(findProperty("integrationTestJavaVersionLegacy16") as String?)?.toInt() ?: 11
|
||||
val integrationTestJavaVersionModern =
|
||||
(findProperty("integrationTestJavaVersionModern") as String?)?.toInt() ?: 25
|
||||
val apiSmokeTestMinecraftVersion =
|
||||
(findProperty("apiSmokeTestMinecraftVersion") as String?) ?: "1.19.2"
|
||||
|
||||
fun parseMinecraftVersion(version: String): Triple<Int, Int, Int> {
|
||||
val parts = version.split(".")
|
||||
val major = parts.getOrNull(0)?.toIntOrNull() ?: 0
|
||||
val minor = parts.getOrNull(1)?.toIntOrNull() ?: 0
|
||||
val patch = parts.getOrNull(2)?.toIntOrNull() ?: 0
|
||||
return Triple(major, minor, patch)
|
||||
}
|
||||
|
||||
fun needsLegacyVanillaJar(version: String): Boolean {
|
||||
val (major, minor, _) = parseMinecraftVersion(version)
|
||||
return major == 1 && minor <= 12
|
||||
}
|
||||
|
||||
fun requiresModernJava(version: String): Boolean {
|
||||
val (major, minor, patch) = parseMinecraftVersion(version)
|
||||
if (major > 1) return true
|
||||
if (minor > 20) return true
|
||||
return minor == 20 && patch >= 5
|
||||
}
|
||||
|
||||
fun requiredJavaVersion(version: String): Int {
|
||||
if (needsLegacyVanillaJar(version)) return integrationTestJavaVersionLegacyPre13
|
||||
if (requiresModernJava(version)) return integrationTestJavaVersionModern
|
||||
val (_, minor, _) = parseMinecraftVersion(version)
|
||||
if (minor <= 16) return integrationTestJavaVersionLegacy16
|
||||
return integrationTestJavaVersionLegacy
|
||||
}
|
||||
|
||||
fun supportsDamageTypeIntegrationDatapack(version: String): Boolean {
|
||||
val (major, minor, patch) = parseMinecraftVersion(version)
|
||||
if (major > 1) return true
|
||||
if (major < 1) return false
|
||||
if (minor > 21) return true
|
||||
return minor == 21 && patch >= 11
|
||||
}
|
||||
|
||||
data class KotestSummary(
|
||||
val specsPassed: Int?,
|
||||
val specsFailed: Int?,
|
||||
val specsTotal: Int?,
|
||||
val testsPassed: Int?,
|
||||
val testsFailed: Int?,
|
||||
val testsIgnored: Int?,
|
||||
val testsTotal: Int?,
|
||||
val failures: List<String>,
|
||||
val failureDetails: List<String>
|
||||
)
|
||||
|
||||
fun parseKotestSummary(logFile: File): KotestSummary? {
|
||||
if (!logFile.exists()) return null
|
||||
|
||||
val lines = logFile.readLines()
|
||||
var inFailures = false
|
||||
var blockContext: String? = null // "specs" or "tests"
|
||||
|
||||
var specsPassed: Int? = null
|
||||
var specsFailed: Int? = null
|
||||
var specsTotal: Int? = null
|
||||
|
||||
var testsPassed: Int? = null
|
||||
var testsFailed: Int? = null
|
||||
var testsIgnored: Int? = null
|
||||
var testsTotal: Int? = null
|
||||
|
||||
val failures = mutableListOf<String>()
|
||||
val failureDetails = mutableListOf<String>()
|
||||
|
||||
val numberLine = Regex("^(\\d+) (passed|failed|ignored|total)$")
|
||||
val inlineSpecsLine = Regex("^Specs:\\s*(\\d+) passed,\\s*(\\d+) failed,\\s*(\\d+) total$")
|
||||
val inlineTestsLine = Regex("^Tests:\\s*(\\d+) passed,\\s*(\\d+) failed,\\s*(\\d+) ignored,\\s*(\\d+) total$")
|
||||
val stackTopLine = Regex("^\\s*[^\\s].*\\(([^)]+:\\d+)\\)\\s*$")
|
||||
|
||||
var lastTestName: String? = null
|
||||
var pendingFailureName: String? = null
|
||||
var pendingFailureMessage: String? = null
|
||||
|
||||
for (raw in lines) {
|
||||
val line = raw.substringAfter("]:", raw).trim()
|
||||
|
||||
when {
|
||||
line.startsWith(">> There were test failures") -> {
|
||||
inFailures = true
|
||||
blockContext = null
|
||||
}
|
||||
|
||||
line.startsWith("Specs:") -> {
|
||||
inFailures = false
|
||||
val inline = inlineSpecsLine.matchEntire(line)
|
||||
if (inline != null) {
|
||||
specsPassed = inline.groupValues[1].toInt()
|
||||
specsFailed = inline.groupValues[2].toInt()
|
||||
specsTotal = inline.groupValues[3].toInt()
|
||||
blockContext = null
|
||||
} else {
|
||||
blockContext = "specs"
|
||||
}
|
||||
}
|
||||
|
||||
line.startsWith("Tests:") -> {
|
||||
inFailures = false
|
||||
val inline = inlineTestsLine.matchEntire(line)
|
||||
if (inline != null) {
|
||||
testsPassed = inline.groupValues[1].toInt()
|
||||
testsFailed = inline.groupValues[2].toInt()
|
||||
testsIgnored = inline.groupValues[3].toInt()
|
||||
testsTotal = inline.groupValues[4].toInt()
|
||||
blockContext = null
|
||||
} else {
|
||||
blockContext = "tests"
|
||||
}
|
||||
}
|
||||
|
||||
inFailures -> {
|
||||
val cleaned = line.removePrefix("-").trim()
|
||||
if (cleaned.isNotBlank()) {
|
||||
failures.add(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
line.startsWith("- ") -> {
|
||||
lastTestName = line.removePrefix("-").trim()
|
||||
}
|
||||
|
||||
line == "FAILED" -> {
|
||||
pendingFailureName = lastTestName
|
||||
pendingFailureMessage = null
|
||||
}
|
||||
|
||||
pendingFailureName != null && pendingFailureMessage == null && line.isNotBlank() -> {
|
||||
// First line after FAILED is usually the assertion message.
|
||||
pendingFailureMessage = line
|
||||
}
|
||||
|
||||
pendingFailureName != null && pendingFailureMessage != null -> {
|
||||
val match = stackTopLine.matchEntire(line)
|
||||
if (match != null) {
|
||||
val location = match.groupValues[1]
|
||||
val name = pendingFailureName!!
|
||||
val message = pendingFailureMessage!!
|
||||
failureDetails.add("$name: $message ($location)")
|
||||
pendingFailureName = null
|
||||
pendingFailureMessage = null
|
||||
}
|
||||
}
|
||||
|
||||
blockContext != null -> {
|
||||
val match = numberLine.matchEntire(line)
|
||||
if (match != null) {
|
||||
val value = match.groupValues[1].toInt()
|
||||
when (blockContext) {
|
||||
"specs" -> {
|
||||
when (match.groupValues[2]) {
|
||||
"passed" -> specsPassed = value
|
||||
"failed" -> specsFailed = value
|
||||
"total" -> specsTotal = value
|
||||
}
|
||||
}
|
||||
|
||||
"tests" -> {
|
||||
when (match.groupValues[2]) {
|
||||
"passed" -> testsPassed = value
|
||||
"failed" -> testsFailed = value
|
||||
"ignored" -> testsIgnored = value
|
||||
"total" -> testsTotal = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
specsPassed == null && specsFailed == null && specsTotal == null &&
|
||||
testsPassed == null && testsFailed == null && testsIgnored == null && testsTotal == null &&
|
||||
failures.isEmpty()
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return KotestSummary(
|
||||
specsPassed,
|
||||
specsFailed,
|
||||
specsTotal,
|
||||
testsPassed,
|
||||
testsFailed,
|
||||
testsIgnored,
|
||||
testsTotal,
|
||||
failures,
|
||||
failureDetails.distinct()
|
||||
)
|
||||
}
|
||||
|
||||
fun sha1(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-1")
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(8192)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read <= 0) break
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
tasks.register("integrationTest") {
|
||||
group = "verification"
|
||||
description = "Runs integration tests against all configured Paper versions."
|
||||
dependsOn("integrationTestMatrix")
|
||||
}
|
||||
|
||||
tasks.named<RunServer>("runServer") {
|
||||
enabled = false
|
||||
description = "Disabled. Use integrationTest/integrationTestMatrix instead."
|
||||
}
|
||||
|
||||
tasks.withType<RunServer>().configureEach {
|
||||
notCompatibleWithConfigurationCache("run-paper tasks access Project at execution time.")
|
||||
}
|
||||
|
||||
val integrationTestMatrixTasks = mutableListOf<TaskProvider<Task>>()
|
||||
var previousMatrixTask: TaskProvider<Task>? = null
|
||||
|
||||
val kotestSpecFilterProvider = providers.systemProperty("kotest.filter.specs")
|
||||
val kotestTestFilterProvider = providers.systemProperty("kotest.filter.tests")
|
||||
|
||||
fun versionTaskSuffix(version: String): String = version.replace(Regex("[^A-Za-z0-9]"), "_")
|
||||
|
||||
for (version in integrationTestVersions) {
|
||||
val suffix = versionTaskSuffix(version)
|
||||
val runDir = file("run/$version")
|
||||
val resultFile = runDir.resolve("plugins/OldCombatMechanicsTest/test-results.txt")
|
||||
val failuresFile = runDir.resolve("plugins/OldCombatMechanicsTest/test-failures.txt")
|
||||
val vanillaCacheFile = runDir.resolve("cache/mojang_$version.jar")
|
||||
val logFile = layout.buildDirectory.file("integration-test-logs/$suffix.log")
|
||||
val datapackSourceDir = layout.projectDirectory.dir("src/integrationTest/datapacks")
|
||||
|
||||
val writePropsTask =
|
||||
tasks.register<WriteProperties>("writeProperties$suffix") {
|
||||
encoding = "UTF-8"
|
||||
property("online-mode", false)
|
||||
destinationFile.set(runDir.resolve("server.properties"))
|
||||
}
|
||||
|
||||
val downloadVanillaTask =
|
||||
if (needsLegacyVanillaJar(version)) {
|
||||
tasks.register("downloadVanilla$suffix") {
|
||||
outputs.file(vanillaCacheFile)
|
||||
notCompatibleWithConfigurationCache("Downloads vanilla server jar for legacy Paper versions.")
|
||||
doLast {
|
||||
val slurper = JsonSlurper()
|
||||
val manifestText =
|
||||
URI("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json")
|
||||
.toURL()
|
||||
.readText()
|
||||
val manifest = slurper.parseText(manifestText) as Map<*, *>
|
||||
val versionsList =
|
||||
manifest["versions"] as? List<Map<*, *>>
|
||||
?: throw GradleException("Invalid Mojang manifest format: missing 'versions' list.")
|
||||
val versionEntry =
|
||||
versionsList.firstOrNull { it["id"] == version }
|
||||
?: throw GradleException("Minecraft version '$version' not found in Mojang manifest.")
|
||||
val versionUrl = versionEntry["url"] as String
|
||||
val versionMetaText = URI(versionUrl).toURL().readText()
|
||||
val versionMeta = slurper.parseText(versionMetaText) as Map<*, *>
|
||||
val downloads = versionMeta["downloads"] as Map<*, *>
|
||||
val serverInfo = downloads["server"] as Map<*, *>
|
||||
val serverUrl = serverInfo["url"] as String
|
||||
val serverSha1 = serverInfo["sha1"] as String
|
||||
|
||||
if (vanillaCacheFile.exists()) {
|
||||
val existingSha1 = sha1(vanillaCacheFile)
|
||||
if (existingSha1.equals(serverSha1, ignoreCase = true)) {
|
||||
return@doLast
|
||||
}
|
||||
} else {
|
||||
vanillaCacheFile.parentFile.mkdirs()
|
||||
}
|
||||
|
||||
val tmpFile = Files.createTempFile("mc-server-$version-", ".jar").toFile()
|
||||
URI(serverUrl).toURL().openStream().use { input ->
|
||||
tmpFile.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
val downloadedSha1 = sha1(tmpFile)
|
||||
if (!downloadedSha1.equals(serverSha1, ignoreCase = true)) {
|
||||
tmpFile.delete()
|
||||
throw GradleException(
|
||||
"Downloaded Minecraft server jar hash mismatch for $version. Expected $serverSha1, got $downloadedSha1."
|
||||
)
|
||||
}
|
||||
tmpFile.copyTo(vanillaCacheFile, overwrite = true)
|
||||
tmpFile.delete()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val runServerTask =
|
||||
tasks.register<RunServer>("runServer$suffix") {
|
||||
dependsOn(writePropsTask)
|
||||
downloadVanillaTask?.let { dependsOn(it) }
|
||||
runDirectory.set(runDir)
|
||||
minecraftVersion(version)
|
||||
jvmArgs("-Dcom.mojang.eula.agree=true")
|
||||
kotestSpecFilterProvider.orNull?.takeIf { it.isNotBlank() }?.let {
|
||||
jvmArgs("-Dkotest.filter.specs=$it")
|
||||
}
|
||||
kotestTestFilterProvider.orNull?.takeIf { it.isNotBlank() }?.let {
|
||||
jvmArgs("-Dkotest.filter.tests=$it")
|
||||
}
|
||||
if (needsLegacyVanillaJar(version)) {
|
||||
// Skip the legacy Paper "outdated build" startup sleep.
|
||||
jvmArgs("-DIReallyKnowWhatIAmDoingISwear=true")
|
||||
}
|
||||
javaLauncher.set(
|
||||
javaToolchains.launcherFor {
|
||||
languageVersion.set(JavaLanguageVersion.of(requiredJavaVersion(version)))
|
||||
}
|
||||
)
|
||||
|
||||
pluginJars.from(shadowJarTask.flatMap { it.archiveFile })
|
||||
pluginJars.from(integrationTestJarTask.flatMap { it.archiveFile })
|
||||
pluginJars.from(configurations["integrationTestServerPlugins"])
|
||||
|
||||
doFirst {
|
||||
val damageTypeDatapackDir = runDir.resolve("world/datapacks/ocmtest-bypasses-cooldown")
|
||||
if (supportsDamageTypeIntegrationDatapack(version)) {
|
||||
copy {
|
||||
from(datapackSourceDir)
|
||||
into(runDir.resolve("world/datapacks"))
|
||||
}
|
||||
} else if (damageTypeDatapackDir.exists()) {
|
||||
delete(damageTypeDatapackDir)
|
||||
}
|
||||
|
||||
val log = logFile.get().asFile
|
||||
log.parentFile.mkdirs()
|
||||
val stream = log.outputStream()
|
||||
standardOutput = stream
|
||||
errorOutput = stream
|
||||
}
|
||||
|
||||
doLast {
|
||||
(standardOutput as? Closeable)?.close()
|
||||
}
|
||||
|
||||
doFirst {
|
||||
if (resultFile.exists()) {
|
||||
resultFile.delete()
|
||||
}
|
||||
if (failuresFile.exists()) {
|
||||
failuresFile.delete()
|
||||
}
|
||||
val ocmConfigFile = runDir.resolve("plugins/OldCombatMechanics/config.yml")
|
||||
if (ocmConfigFile.exists()) {
|
||||
ocmConfigFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val checkTask =
|
||||
tasks.register("checkTestResults$suffix") {
|
||||
doLast {
|
||||
if (!resultFile.exists()) {
|
||||
throw GradleException("Test results file not found for $version. Tests may not have run correctly.")
|
||||
}
|
||||
val result = resultFile.readText().trim()
|
||||
val log = logFile.get().asFile
|
||||
val summary = parseKotestSummary(log)
|
||||
summary?.let {
|
||||
val parts = mutableListOf<String>()
|
||||
if (it.specsTotal != null) {
|
||||
parts.add(
|
||||
"Specs: ${it.specsPassed ?: "?"} passed, ${it.specsFailed ?: "?"} failed, ${it.specsTotal} total"
|
||||
)
|
||||
}
|
||||
if (it.testsTotal != null) {
|
||||
parts.add(
|
||||
"Tests: ${it.testsPassed ?: "?"} passed, ${it.testsFailed ?: "?"} failed, ${it.testsIgnored ?: 0} ignored, ${it.testsTotal} total"
|
||||
)
|
||||
}
|
||||
if (it.failures.isNotEmpty()) {
|
||||
parts.add("Failures: ${it.failures.joinToString(", ")}")
|
||||
}
|
||||
if (it.failureDetails.isNotEmpty()) {
|
||||
parts.add("Reasons: ${it.failureDetails.take(2).joinToString("; ")}")
|
||||
}
|
||||
if (parts.isNotEmpty()) {
|
||||
logger.lifecycle("[$version] ${parts.joinToString(" | ")}")
|
||||
}
|
||||
} ?: run {
|
||||
val rel = log.relativeToOrNull(project.layout.projectDirectory.asFile)?.path ?: log.absolutePath
|
||||
logger.lifecycle("[$version] No Kotest summary parsed. Full log: $rel")
|
||||
}
|
||||
run {
|
||||
val rel = log.relativeToOrNull(project.layout.projectDirectory.asFile)?.path ?: log.absolutePath
|
||||
logger.lifecycle("[$version] Log: $rel")
|
||||
}
|
||||
if (failuresFile.exists()) {
|
||||
val lines = failuresFile.readLines().map { it.trim() }.filter { it.isNotEmpty() }
|
||||
if (lines.isNotEmpty()) {
|
||||
logger.lifecycle("[$version] Failure details: ${lines.take(5).joinToString(" | ")}")
|
||||
}
|
||||
}
|
||||
if (result == "FAIL") {
|
||||
throw GradleException("Integration tests failed for $version.")
|
||||
} else if (result != "PASS") {
|
||||
throw GradleException("Unknown test result for $version: $result")
|
||||
}
|
||||
logger.lifecycle("Integration tests passed for $version.")
|
||||
}
|
||||
}
|
||||
|
||||
val testTask =
|
||||
tasks.register("integrationTest$suffix") {
|
||||
group = "verification"
|
||||
description = "Runs integration tests with a live Paper server ($version)."
|
||||
dependsOn(shadowJarTask, integrationTestJarTask, runServerTask)
|
||||
finalizedBy(checkTask)
|
||||
}
|
||||
|
||||
val priorTask = previousMatrixTask
|
||||
if (priorTask != null) {
|
||||
// Chain tasks to enforce order and fail fast.
|
||||
testTask.configure { dependsOn(priorTask) }
|
||||
}
|
||||
previousMatrixTask = testTask
|
||||
integrationTestMatrixTasks.add(testTask)
|
||||
}
|
||||
|
||||
tasks.register("integrationTestMatrix") {
|
||||
group = "verification"
|
||||
description = "Runs integration tests against multiple Paper versions."
|
||||
dependsOn(integrationTestMatrixTasks)
|
||||
}
|
||||
|
||||
val apiSmokeTestSuffix = versionTaskSuffix(apiSmokeTestMinecraftVersion)
|
||||
val apiSmokeRunDir = file("run/api-smoke-$apiSmokeTestMinecraftVersion")
|
||||
val apiSmokeResultFile = apiSmokeRunDir.resolve("plugins/OldCombatMechanicsApiSmokeTest/test-results.txt")
|
||||
val apiSmokeWritePropertiesTask =
|
||||
tasks.register<WriteProperties>("writeApiSmokeProperties$apiSmokeTestSuffix") {
|
||||
encoding = "UTF-8"
|
||||
property("online-mode", false)
|
||||
destinationFile.set(apiSmokeRunDir.resolve("server.properties"))
|
||||
}
|
||||
|
||||
val runApiSmokeServerTask =
|
||||
tasks.register<RunServer>("runApiSmokeServer$apiSmokeTestSuffix") {
|
||||
dependsOn(apiSmokeWritePropertiesTask, shadowJarTask, apiSmokeTestJarTask)
|
||||
runDirectory.set(apiSmokeRunDir)
|
||||
minecraftVersion(apiSmokeTestMinecraftVersion)
|
||||
jvmArgs("-Dcom.mojang.eula.agree=true")
|
||||
javaLauncher.set(
|
||||
javaToolchains.launcherFor {
|
||||
languageVersion.set(JavaLanguageVersion.of(requiredJavaVersion(apiSmokeTestMinecraftVersion)))
|
||||
}
|
||||
)
|
||||
|
||||
pluginJars.from(shadowJarTask.flatMap { it.archiveFile })
|
||||
pluginJars.from(apiSmokeTestJarTask.flatMap { it.archiveFile })
|
||||
|
||||
doFirst {
|
||||
if (apiSmokeResultFile.exists()) {
|
||||
apiSmokeResultFile.delete()
|
||||
}
|
||||
val ocmConfigFile = apiSmokeRunDir.resolve("plugins/OldCombatMechanics/config.yml")
|
||||
if (ocmConfigFile.exists()) {
|
||||
ocmConfigFile.delete()
|
||||
}
|
||||
val pluginNames = pluginJars.files.map { it.name }
|
||||
if (pluginNames.any { it.contains("tests", ignoreCase = true) }) {
|
||||
throw GradleException("apiSmokeTest must not load the integration-test plugin jar: $pluginNames")
|
||||
}
|
||||
logger.lifecycle("API smoke server plugins: ${pluginNames.joinToString(", ")}")
|
||||
}
|
||||
}
|
||||
|
||||
val checkApiSmokeResultsTask =
|
||||
tasks.register("checkApiSmokeResults$apiSmokeTestSuffix") {
|
||||
doLast {
|
||||
if (!apiSmokeResultFile.exists()) {
|
||||
throw GradleException(
|
||||
"API smoke result file not found for $apiSmokeTestMinecraftVersion: ${apiSmokeResultFile.relativeTo(
|
||||
projectDir
|
||||
)}"
|
||||
)
|
||||
}
|
||||
val result = apiSmokeResultFile.readText().trim()
|
||||
logger.lifecycle("API smoke result for $apiSmokeTestMinecraftVersion: $result")
|
||||
if (result != "PASS") {
|
||||
throw GradleException("API smoke test failed for $apiSmokeTestMinecraftVersion: $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("apiSmokeTest") {
|
||||
group = "verification"
|
||||
description = "Runs the Java API smoke plugin against a live Paper server ($apiSmokeTestMinecraftVersion)."
|
||||
dependsOn(runApiSmokeServerTask)
|
||||
finalizedBy(checkApiSmokeResultsTask)
|
||||
}
|
||||
|
||||
val versionStringProvider = providers.provider { project.version.toString() }
|
||||
val isReleaseProvider = versionStringProvider.map { !it.contains('-') }
|
||||
|
||||
val gitShortHashProvider =
|
||||
providers
|
||||
.exec {
|
||||
commandLine("git", "rev-parse", "--short", "HEAD")
|
||||
}.standardOutput.asText
|
||||
.map { it.trim() }
|
||||
|
||||
val gitChangelogProvider =
|
||||
providers
|
||||
.exec {
|
||||
commandLine("git", "log", "-1", "--pretty=%B")
|
||||
}.standardOutput.asText
|
||||
.map { it.trim() }
|
||||
|
||||
val suffixedVersionProvider =
|
||||
providers.provider {
|
||||
val version = project.version.toString()
|
||||
if (!version.contains('-')) {
|
||||
version
|
||||
} else {
|
||||
"$version+${gitShortHashProvider.get()}"
|
||||
}
|
||||
}
|
||||
|
||||
val changelogProvider = providers.environmentVariable("HANGAR_CHANGELOG").orElse(gitChangelogProvider)
|
||||
|
||||
tasks.register("printIsRelease") {
|
||||
doLast {
|
||||
println(if (!project.version.toString().contains('-')) "true" else "false")
|
||||
}
|
||||
}
|
||||
|
||||
hangarPublish {
|
||||
publications.register("plugin") {
|
||||
version.set(suffixedVersionProvider)
|
||||
channel.set(isReleaseProvider.map { if (it) "Release" else "Snapshot" })
|
||||
id.set("OldCombatMechanics")
|
||||
apiKey.set(System.getenv("HANGAR_API_TOKEN"))
|
||||
changelog.set(changelogProvider)
|
||||
platforms {
|
||||
register(Platforms.PAPER) {
|
||||
jar.set(tasks.shadowJar.flatMap { it.archiveFile })
|
||||
platformVersions.set(paperVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
gameVersions=26.1.2, 26.1.1, 1.21.11, 1.21.10, 1.21.9, 1.21.8, 1.21.7, 1.21.6, 1.21.5, 1.21.4, 1.21.3, 1.21.2, 1.21.1, 1.21, 1.20.6, 1.20.5, 1.20.4, 1.20.3, 1.20.2, 1.20.1, 1.20, 1.19.4, 1.19.3, 1.19.2, 1.19.1, 1.19, 1.18.2, 1.18.1, 1.18, 1.17, 1.16, 1.15, 1.14, 1.13, 1.12, 1.11, 1.10, 1.9
|
||||
|
||||
# Disable configuration cache (run-paper tasks are not compatible).
|
||||
org.gradle.configuration-cache=false
|
||||
@@ -0,0 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,82 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
|
||||
xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>me.vagdedes</groupId>
|
||||
<artifactId>spartan-api</artifactId>
|
||||
<version>190</version>
|
||||
<description>POM was created from install:install-file</description>
|
||||
</project>
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<metadata>
|
||||
<groupId>me.vagdedes</groupId>
|
||||
<artifactId>spartan-api</artifactId>
|
||||
<versioning>
|
||||
<release>190</release>
|
||||
<versions>
|
||||
<version>190</version>
|
||||
</versions>
|
||||
<lastUpdated>20180610163102</lastUpdated>
|
||||
</versioning>
|
||||
</metadata>
|
||||
@@ -1,134 +0,0 @@
|
||||
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>kernitus.plugin.OldCombatMechanics</groupId>
|
||||
<artifactId>OldCombatMechanics</artifactId>
|
||||
<version>1.7.3</version>
|
||||
<name>OldCombatMechanics</name>
|
||||
<description>Implements pre-1.9 combat-related mechanics</description>
|
||||
<url>https://github.com/kernitus/BukkitOldCombatMechanics</url>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
</properties>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>bukkit</id>
|
||||
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>gravity-repo</id>
|
||||
<url>http://repo.gravitydevelopment.net</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>bstats-repo</id>
|
||||
<url>http://repo.bstats.org/content/repositories/releases/</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>placeholderapi-repo</id>
|
||||
<url>http://repo.extendedclip.com/content/repositories/placeholderapi/</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>local-file-repo</id>
|
||||
<url>file://${basedir}/internal-file-repo</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.bukkit</groupId>
|
||||
<artifactId>bukkit</artifactId>
|
||||
<version>1.13.2-R0.1-SNAPSHOT</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.gravitydevelopment.updater</groupId>
|
||||
<artifactId>updater</artifactId>
|
||||
<version>2.4</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.bstats</groupId>
|
||||
<artifactId>bstats-bukkit</artifactId>
|
||||
<version>1.2</version>
|
||||
</dependency>
|
||||
<!--Shaded in by bukkit -->
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-all</artifactId>
|
||||
<version>4.1.21.Final</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>me.clip</groupId>
|
||||
<artifactId>placeholderapi</artifactId>
|
||||
<version>2.8.6</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!--SPARTAN API-->
|
||||
<dependency>
|
||||
<groupId>me.vagdedes</groupId>
|
||||
<artifactId>spartan-api</artifactId>
|
||||
<version>190</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<defaultGoal>clean package</defaultGoal>
|
||||
<finalName>${project.artifactId}</finalName>
|
||||
<sourceDirectory>src/main/java</sourceDirectory>
|
||||
<resources>
|
||||
<resource>
|
||||
<targetPath>.</targetPath>
|
||||
<filtering>true</filtering>
|
||||
<directory>${basedir}/src/main/resources</directory>
|
||||
<includes>
|
||||
<include>**/*</include>
|
||||
</includes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.8.1</version>
|
||||
<configuration>
|
||||
<source>1.8</source>
|
||||
<target>1.8</target>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.2.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<artifactSet>
|
||||
<includes>
|
||||
<include>net.gravitydevelopment.updater:*</include>
|
||||
<include>org.bstats:*</include>
|
||||
</includes>
|
||||
</artifactSet>
|
||||
<relocations>
|
||||
<relocation>
|
||||
<pattern>org.bstats</pattern>
|
||||
<shadedPattern>kernitus.plugin.OldCombatMechanics.lib.bstats</shadedPattern>
|
||||
</relocation>
|
||||
<relocation>
|
||||
<pattern>net.gravitydevelopment.updater</pattern>
|
||||
<shadedPattern>kernitus.plugin.OldCombatMechanics.lib.updater</shadedPattern>
|
||||
</relocation>
|
||||
</relocations>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 881 B |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 800 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 604 B |
@@ -0,0 +1,70 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/run-integration-matrix.sh [-v versions] [-s spec-filter] [-t test-filter] [--] [extra Gradle args...]
|
||||
|
||||
Wraps ./gradlew integrationTest using repository-relative paths.
|
||||
|
||||
Options:
|
||||
-v, --versions LIST Comma-separated Minecraft versions, for example 1.19.2,1.21.11
|
||||
-s, --spec-filter TEXT Kotest spec filter passed as -Dkotest.filter.specs=TEXT
|
||||
-t, --test-filter TEXT Kotest test filter passed as -Dkotest.filter.tests=TEXT
|
||||
-h, --help Show this help text
|
||||
|
||||
This command may start Paper servers. For a non-invasive check, use
|
||||
scripts/validate-module-assignments.sh or scripts/summarise-integration-results.sh.
|
||||
USAGE
|
||||
}
|
||||
|
||||
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
versions=
|
||||
spec_filter=
|
||||
test_filter=
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-v|--versions)
|
||||
[ "$#" -ge 2 ] || { printf '%s\n' "Missing value for $1" >&2; usage >&2; exit 2; }
|
||||
versions=$2
|
||||
shift 2
|
||||
;;
|
||||
-s|--spec-filter)
|
||||
[ "$#" -ge 2 ] || { printf '%s\n' "Missing value for $1" >&2; usage >&2; exit 2; }
|
||||
spec_filter=$2
|
||||
shift 2
|
||||
;;
|
||||
-t|--test-filter)
|
||||
[ "$#" -ge 2 ] || { printf '%s\n' "Missing value for $1" >&2; usage >&2; exit 2; }
|
||||
test_filter=$2
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
-*)
|
||||
printf '%s\n' "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
set -- integrationTest "$@"
|
||||
[ -z "$versions" ] || set -- "$@" "-PintegrationTestVersions=$versions"
|
||||
[ -z "$spec_filter" ] || set -- "$@" "-Dkotest.filter.specs=$spec_filter"
|
||||
[ -z "$test_filter" ] || set -- "$@" "-Dkotest.filter.tests=$test_filter"
|
||||
|
||||
printf '%s\n' "Repository: $repo_root"
|
||||
printf '%s\n' "Running: ./gradlew $*"
|
||||
cd "$repo_root"
|
||||
exec ./gradlew "$@"
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/suggest-integration-matrix.sh [-h] [changed-path ...]
|
||||
|
||||
Suggests a small integration-test matrix from changed paths. With no paths, it
|
||||
tries git diff --name-only. Suggestions are conservative hints, not a quality
|
||||
gate.
|
||||
USAGE
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
if command -v git >/dev/null 2>&1 && git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
set -- $(git -C "$repo_root" diff --name-only -- src src/integrationTest build.gradle.kts gradle.properties || true)
|
||||
fi
|
||||
fi
|
||||
|
||||
versions="1.19.2,1.21.11"
|
||||
reason="default modern coverage for uncertain changes"
|
||||
filters=""
|
||||
|
||||
for path in "$@"; do
|
||||
case "$path" in
|
||||
src/main/resources/config.yml|*config*|*modeset*|*Module*)
|
||||
versions="1.12,1.19.2,1.21.11"
|
||||
reason="config or module assignment changes benefit from legacy and modern coverage"
|
||||
filters="-Dkotest.filter.specs=*Module*,*Config*,*Modeset*"
|
||||
;;
|
||||
*Packet*|*packet*|*FakePlayer*|*fake-player*)
|
||||
versions="1.19.2,1.21.11"
|
||||
reason="packet or fake-player areas need modern Paper/PacketEvents coverage"
|
||||
;;
|
||||
*reflection*|*nms*|*legacy*|*1_12*)
|
||||
versions="1.12,1.19.2"
|
||||
reason="legacy or reflective code should include the Java 8-era path"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
printf 'Recommended versions: %s\n' "$versions"
|
||||
printf 'Reason: %s\n' "$reason"
|
||||
[ -z "$filters" ] || printf 'Suggested filter: %s\n' "$filters"
|
||||
printf 'Run with: scripts/run-integration-matrix.sh -v %s' "$versions"
|
||||
[ -z "$filters" ] || printf ' -- %s' "$filters"
|
||||
printf '\n'
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/summarise-integration-results.sh [-h]
|
||||
|
||||
Reports compact integration-test outcomes from run/* plugin result files.
|
||||
It deliberately does not open build/integration-test-logs/*.log. If compact
|
||||
results are insufficient, ask explicitly before inspecting full server logs.
|
||||
USAGE
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
'') ;;
|
||||
*)
|
||||
printf '%s\n' "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
found=0
|
||||
failed=0
|
||||
|
||||
for run_dir in "$repo_root"/run/*; do
|
||||
[ -d "$run_dir" ] || continue
|
||||
version=${run_dir##*/}
|
||||
result_file=$run_dir/plugins/OldCombatMechanicsTest/test-results.txt
|
||||
failures_file=$run_dir/plugins/OldCombatMechanicsTest/test-failures.txt
|
||||
|
||||
if [ ! -f "$result_file" ] && [ ! -f "$failures_file" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
found=1
|
||||
result="MISSING"
|
||||
[ ! -f "$result_file" ] || result=$(tr -d '\r' < "$result_file" | sed '/^[[:space:]]*$/d' | sed -n '1p')
|
||||
|
||||
printf '%s: %s\n' "$version" "$result"
|
||||
if [ "$result" != "PASS" ]; then
|
||||
failed=1
|
||||
fi
|
||||
|
||||
if [ -s "$failures_file" ]; then
|
||||
sed 's/^/ /' "$failures_file"
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -eq 0 ]; then
|
||||
printf '%s\n' 'No compact integration-test result files found under run/*/plugins/OldCombatMechanicsTest/.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit "$failed"
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/validate-module-assignments.sh [-h] [config-file]
|
||||
|
||||
Best-effort static validation for bundled module assignment lists.
|
||||
Checks that every listed module is assigned to only one configurable top-level
|
||||
category: always_enabled_modules, disabled_modules, or the aggregate modesets
|
||||
category. It also checks duplicate entries within the same exact list or
|
||||
modeset, and that internal modules are not listed in those configurable
|
||||
sections. Reusing a module in more than one modeset is allowed because modesets
|
||||
are alternative player modes.
|
||||
|
||||
The default config file is src/main/resources/config.yml, resolved relative to
|
||||
the repository root. This script does not start servers.
|
||||
USAGE
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
config_file=${1:-"$repo_root/src/main/resources/config.yml"}
|
||||
|
||||
python3 - "$config_file" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
config = Path(sys.argv[1])
|
||||
if not config.is_file():
|
||||
print(f"Config file not found: {config}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
internal = {"modeset-listener", "attack-cooldown-tracker", "entity-damage-listener"}
|
||||
assignments = defaultdict(list)
|
||||
section = None
|
||||
modeset = None
|
||||
|
||||
top_level = re.compile(r"^([A-Za-z0-9_.-]+):\s*(?:#.*)?$")
|
||||
mode_name = re.compile(r"^ ([A-Za-z0-9_.-]+):\s*(?:#.*)?$")
|
||||
list_item = re.compile(r"^\s*-\s*['\"]?([^'\"#\s]+)['\"]?\s*(?:#.*)?$")
|
||||
|
||||
for number, raw in enumerate(config.read_text(encoding="utf-8").splitlines(), 1):
|
||||
top = top_level.match(raw)
|
||||
if top:
|
||||
section = top.group(1)
|
||||
modeset = None
|
||||
continue
|
||||
|
||||
if section == "modesets":
|
||||
mode = mode_name.match(raw)
|
||||
if mode:
|
||||
modeset = mode.group(1)
|
||||
continue
|
||||
|
||||
item = list_item.match(raw)
|
||||
if not item:
|
||||
continue
|
||||
|
||||
module = item.group(1)
|
||||
if section == "always_enabled_modules":
|
||||
category = "always_enabled_modules"
|
||||
bucket = "always_enabled_modules"
|
||||
elif section == "disabled_modules":
|
||||
category = "disabled_modules"
|
||||
bucket = "disabled_modules"
|
||||
elif section == "modesets" and modeset:
|
||||
category = "modesets"
|
||||
bucket = f"modesets.{modeset}"
|
||||
else:
|
||||
continue
|
||||
assignments[module].append((category, bucket, number))
|
||||
|
||||
errors = []
|
||||
for module, locations in sorted(assignments.items()):
|
||||
if module in internal:
|
||||
for _category, bucket, number in locations:
|
||||
errors.append(f"internal module listed: {module} in {bucket} at line {number}")
|
||||
exact_locations = defaultdict(list)
|
||||
category_locations = defaultdict(list)
|
||||
for category, bucket, number in locations:
|
||||
exact_locations[bucket].append(number)
|
||||
category_locations[category].append((bucket, number))
|
||||
if len(category_locations) > 1:
|
||||
where = ", ".join(
|
||||
f"{bucket}:line {number}"
|
||||
for _category, bucket, number in locations
|
||||
)
|
||||
errors.append(f"conflicting module assignment: {module} in {where}")
|
||||
for bucket, numbers in sorted(exact_locations.items()):
|
||||
if len(numbers) > 1:
|
||||
where = ", ".join(f"line {number}" for number in numbers)
|
||||
errors.append(f"duplicate module assignment within {bucket}: {module} at {where}")
|
||||
|
||||
if errors:
|
||||
print("Module assignment validation FAILED:")
|
||||
for error in errors:
|
||||
print(f"- {error}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Module assignment validation passed for {config}")
|
||||
print(f"Checked {len(assignments)} assigned module entr{'y' if len(assignments) == 1 else 'ies'}.")
|
||||
PY
|
||||
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
rootProject.name = "OldCombatMechanics"
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package kernitus.plugin.OldCombatMechanics.apiSmokeTest;
|
||||
|
||||
import kernitus.plugin.OldCombatMechanics.api.OldCombatMechanicsAPI;
|
||||
import kernitus.plugin.OldCombatMechanics.api.PlayerModesetChangeEvent;
|
||||
import kernitus.plugin.OldCombatMechanics.api.PlayerModuleOverride;
|
||||
import kernitus.plugin.OldCombatMechanics.api.PlayerModuleOverrideChangeEvent;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.RegisteredServiceProvider;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Set;
|
||||
|
||||
public final class ApiSmokeTestPlugin extends JavaPlugin {
|
||||
|
||||
private static final String KNOWN_CONFIGURABLE_MODULE = "sword-blocking";
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
try {
|
||||
RegisteredServiceProvider<OldCombatMechanicsAPI> registration =
|
||||
Bukkit.getServicesManager().getRegistration(OldCombatMechanicsAPI.class);
|
||||
if (registration == null) {
|
||||
throw new IllegalStateException("OldCombatMechanicsAPI service is not registered");
|
||||
}
|
||||
|
||||
OldCombatMechanicsAPI api = registration.getProvider();
|
||||
if (!api.getModuleNames().contains(KNOWN_CONFIGURABLE_MODULE)) {
|
||||
throw new IllegalStateException("OldCombatMechanicsAPI is missing known configurable module "
|
||||
+ KNOWN_CONFIGURABLE_MODULE);
|
||||
}
|
||||
Set<String> modesetNames = api.getModesetNames();
|
||||
if (modesetNames.isEmpty()) {
|
||||
throw new IllegalStateException("OldCombatMechanicsAPI did not expose any modesets");
|
||||
}
|
||||
World world = Bukkit.getWorlds().get(0);
|
||||
Set<String> allowedModesets = api.getAllowedModesets(world);
|
||||
if (allowedModesets.isEmpty()) {
|
||||
throw new IllegalStateException("OldCombatMechanicsAPI did not expose allowed modesets for "
|
||||
+ world.getName());
|
||||
}
|
||||
writeResult("PASS");
|
||||
} catch (Throwable throwable) {
|
||||
getLogger().severe("API smoke test failed: " + throwable.getMessage());
|
||||
try {
|
||||
writeResult("FAIL");
|
||||
} catch (IOException ioException) {
|
||||
getLogger().severe("Could not write API smoke test result: " + ioException.getMessage());
|
||||
}
|
||||
} finally {
|
||||
Bukkit.getScheduler().runTask(this, Bukkit::shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeResult(String result) throws IOException {
|
||||
File resultFile = new File(getDataFolder(), "test-results.txt");
|
||||
File parent = resultFile.getParentFile();
|
||||
if (parent != null) {
|
||||
parent.mkdirs();
|
||||
}
|
||||
Files.write(resultFile.toPath(), result.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private void verifyPlayerSpecificApiCompiles(OldCombatMechanicsAPI api, Player player) {
|
||||
api.forceEnableModuleForPlayer(player, KNOWN_CONFIGURABLE_MODULE);
|
||||
api.isModuleEnabledForPlayer(player, KNOWN_CONFIGURABLE_MODULE);
|
||||
api.clearModuleOverrideForPlayer(player, KNOWN_CONFIGURABLE_MODULE);
|
||||
api.setModuleOverridesForPlayer(player, java.util.Collections.singletonMap(
|
||||
KNOWN_CONFIGURABLE_MODULE,
|
||||
PlayerModuleOverride.FORCE_ENABLED
|
||||
));
|
||||
String currentModeset = api.getModesetForPlayer(player);
|
||||
if (currentModeset != null) {
|
||||
api.setModesetForPlayer(player, currentModeset);
|
||||
}
|
||||
PlayerModesetChangeEvent modesetEvent = new PlayerModesetChangeEvent(
|
||||
player,
|
||||
player.getWorld(),
|
||||
currentModeset,
|
||||
currentModeset,
|
||||
PlayerModesetChangeEvent.Reason.API
|
||||
);
|
||||
modesetEvent.getReason();
|
||||
PlayerModuleOverrideChangeEvent overrideEvent = new PlayerModuleOverrideChangeEvent(
|
||||
player,
|
||||
KNOWN_CONFIGURABLE_MODULE,
|
||||
PlayerModuleOverride.DEFAULT,
|
||||
PlayerModuleOverride.FORCE_ENABLED
|
||||
);
|
||||
overrideEvent.getNewOverride();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
main: kernitus.plugin.OldCombatMechanics.apiSmokeTest.ApiSmokeTestPlugin
|
||||
name: OldCombatMechanicsApiSmokeTest
|
||||
version: 1.0.0
|
||||
depend: [OldCombatMechanics]
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"ocmtest:tick_damage"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"message_id": "ocmtest.tick_damage",
|
||||
"scaling": "never",
|
||||
"exhaustion": 0.0
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"pack": {
|
||||
"pack_format": 81,
|
||||
"description": "OCM bypasses cooldown integration-test datapack"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package kernitus.plugin.OldCombatMechanics;
|
||||
|
||||
import kernitus.plugin.OldCombatMechanics.api.OldCombatMechanicsAPI;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
final class PlayerModuleOverrideJavaInterop {
|
||||
|
||||
private PlayerModuleOverrideJavaInterop() {
|
||||
}
|
||||
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
static void setOverridesWithNullValue(OldCombatMechanicsAPI api, Player player, String moduleName) {
|
||||
Map overrides = new HashMap();
|
||||
overrides.put(moduleName, null);
|
||||
api.setModuleOverridesForPlayer(player, overrides);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.CompatibilityCapabilities
|
||||
import org.bukkit.entity.Entity
|
||||
import org.bukkit.entity.LivingEntity
|
||||
import org.bukkit.entity.Player
|
||||
import java.lang.reflect.Method
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
fun attackCompat(attacker: Player, target: Entity) {
|
||||
val apiAttack = attacker.javaClass.methods.firstOrNull { method ->
|
||||
method.name == "attack" &&
|
||||
method.parameterCount == 1 &&
|
||||
Entity::class.java.isAssignableFrom(method.parameterTypes[0])
|
||||
}
|
||||
val useApiAttack = apiAttack != null && CompatibilityCapabilities.isPlayerAttackApiAvailable(attacker.javaClass)
|
||||
if (useApiAttack) {
|
||||
val attackMethod = apiAttack
|
||||
val beforeApiAttack = captureLivingAttackSignal(target)
|
||||
try {
|
||||
val apiResult = attackMethod.invoke(attacker, target)
|
||||
if (attackMethod.returnType == java.lang.Boolean.TYPE && apiResult == false) {
|
||||
// Explicit attack failure; continue with NMS candidates.
|
||||
} else if (beforeApiAttack == null) {
|
||||
return
|
||||
} else {
|
||||
val afterApiAttack = captureLivingAttackSignal(target)
|
||||
if (hasObservableHit(beforeApiAttack, afterApiAttack)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (ignored: Exception) {
|
||||
// Fall through to NMS-based attack.
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to NMS attack when the Bukkit API attack path is absent or unobservable.
|
||||
val handleMethod = attacker.javaClass.methods.firstOrNull { method ->
|
||||
method.name == "getHandle" && method.parameterCount == 0
|
||||
} ?: error("Failed to resolve CraftPlayer#getHandle for ${attacker.javaClass.name}")
|
||||
|
||||
val attackerHandle = handleMethod.invoke(attacker)
|
||||
?: error("CraftPlayer#getHandle returned null for ${attacker.javaClass.name}")
|
||||
|
||||
val targetHandle = target.javaClass.methods.firstOrNull { method ->
|
||||
method.name == "getHandle" && method.parameterCount == 0
|
||||
}?.invoke(target) ?: error("Failed to resolve CraftEntity#getHandle for ${target.javaClass.name}")
|
||||
|
||||
val nmsAttackMethods = resolveNmsAttackMethods(attackerHandle.javaClass, targetHandle.javaClass)
|
||||
var falseResultCount = 0
|
||||
var exceptionCount = 0
|
||||
val attemptedMethods = ArrayList<String>(nmsAttackMethods.size)
|
||||
|
||||
for (method in nmsAttackMethods) {
|
||||
attemptedMethods += "${method.declaringClass.simpleName}#${method.name}:${method.returnType.simpleName}"
|
||||
try {
|
||||
val result = method.invoke(attackerHandle, targetHandle)
|
||||
if (method.returnType == java.lang.Boolean.TYPE && result == false) {
|
||||
falseResultCount++
|
||||
continue
|
||||
}
|
||||
return
|
||||
} catch (ignored: Exception) {
|
||||
exceptionCount++
|
||||
// Try the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
// Last-resort fallback: try Bukkit API even when the capability probe was inconclusive.
|
||||
if (!useApiAttack && apiAttack != null) {
|
||||
runCatching { apiAttack.invoke(attacker, target); return }
|
||||
}
|
||||
|
||||
error(
|
||||
"Failed to invoke NMS attack for attacker=${attackerHandle.javaClass.name} " +
|
||||
"target=${targetHandle.javaClass.name} (candidates=${nmsAttackMethods.size}, " +
|
||||
"falseResults=$falseResultCount, exceptions=$exceptionCount, attempted=$attemptedMethods)"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private data class LivingAttackSignal(
|
||||
val health: Double,
|
||||
val lastDamage: Double,
|
||||
val noDamageTicks: Int,
|
||||
)
|
||||
|
||||
private fun captureLivingAttackSignal(entity: Entity): LivingAttackSignal? {
|
||||
val living = entity as? LivingEntity ?: return null
|
||||
return LivingAttackSignal(
|
||||
health = living.health,
|
||||
lastDamage = living.lastDamage,
|
||||
noDamageTicks = living.noDamageTicks,
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasObservableHit(before: LivingAttackSignal, after: LivingAttackSignal?): Boolean {
|
||||
if (after == null) return false
|
||||
if (after.health < before.health) return true
|
||||
if (after.noDamageTicks > before.noDamageTicks) return true
|
||||
return after.lastDamage > 0.0 && after.lastDamage != before.lastDamage
|
||||
}
|
||||
|
||||
private val attackMethodCache = ConcurrentHashMap<Class<*>, List<Method>>()
|
||||
|
||||
private fun resolveNmsAttackMethods(attackerHandleClass: Class<*>, targetHandleClass: Class<*>): List<Method> {
|
||||
return attackMethodCache.computeIfAbsent(attackerHandleClass) {
|
||||
buildAttackMethodCandidates(attackerHandleClass, targetHandleClass)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAttackMethodCandidates(attackerHandleClass: Class<*>, targetHandleClass: Class<*>): List<Method> {
|
||||
// Prefer explicit names if they exist, then fall back to signature-based heuristics.
|
||||
val explicit = listOfNotNull(
|
||||
Reflector.getMethodAssignable(attackerHandleClass, "attack", targetHandleClass),
|
||||
Reflector.getMethodAssignable(attackerHandleClass, "a", targetHandleClass),
|
||||
Reflector.getMethodAssignable(attackerHandleClass, "B", targetHandleClass) // legacy 1.12 variants
|
||||
)
|
||||
if (explicit.isNotEmpty()) {
|
||||
explicit.forEach { it.isAccessible = true }
|
||||
return explicit
|
||||
}
|
||||
|
||||
val candidates = collectAllMethods(attackerHandleClass)
|
||||
.asSequence()
|
||||
.filter { it.parameterCount == 1 }
|
||||
.filter { it.parameterTypes[0].isAssignableFrom(targetHandleClass) }
|
||||
.filter { it.returnType == Void.TYPE || it.returnType == java.lang.Boolean.TYPE }
|
||||
.map { method -> method to scoreAttackMethod(method) }
|
||||
.sortedByDescending { it.second }
|
||||
.map { it.first }
|
||||
.toList()
|
||||
|
||||
candidates.forEach { it.isAccessible = true }
|
||||
return candidates
|
||||
}
|
||||
|
||||
private fun collectAllMethods(start: Class<*>): List<Method> {
|
||||
val methods = LinkedHashMap<String, Method>()
|
||||
var current: Class<*>? = start
|
||||
while (current != null) {
|
||||
current.declaredMethods.forEach { method ->
|
||||
methods.putIfAbsent(methodSignature(method), method)
|
||||
}
|
||||
current = current.superclass
|
||||
}
|
||||
start.methods.forEach { method ->
|
||||
methods.putIfAbsent(methodSignature(method), method)
|
||||
}
|
||||
return methods.values.toList()
|
||||
}
|
||||
|
||||
private fun methodSignature(method: Method): String {
|
||||
val params = method.parameterTypes.joinToString(",") { it.name }
|
||||
return "${method.declaringClass.name}#${method.name}($params):${method.returnType.name}"
|
||||
}
|
||||
|
||||
private fun scoreAttackMethod(method: Method): Int {
|
||||
var score = 0
|
||||
val name = method.name
|
||||
val param = method.parameterTypes[0]
|
||||
val declaring = method.declaringClass.simpleName
|
||||
|
||||
if (name == "attack") score += 100
|
||||
if (name == "a") score += 80
|
||||
|
||||
if (param.simpleName == "Entity") score += 40
|
||||
if (param.simpleName.contains("Entity")) score += 10
|
||||
|
||||
if (method.returnType == Void.TYPE) score += 10
|
||||
if (method.returnType == java.lang.Boolean.TYPE) score += 8
|
||||
|
||||
if (declaring.contains("EntityHuman")) score += 25
|
||||
if (declaring.contains("EntityPlayer")) score += 20
|
||||
|
||||
return score
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import com.cryptomorin.xseries.XAttribute
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.doubles.plusOrMinus
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleAttackCooldown
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.Config
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage
|
||||
import kotlinx.coroutines.delay
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.World
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.EventPriority
|
||||
import org.bukkit.event.HandlerList
|
||||
import org.bukkit.event.Listener
|
||||
import org.bukkit.event.player.PlayerChangedWorldEvent
|
||||
import org.bukkit.event.player.PlayerItemHeldEvent
|
||||
import org.bukkit.event.player.PlayerJoinEvent
|
||||
import org.bukkit.event.player.PlayerSwapHandItemsEvent
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class AttackCooldownHeldItemIntegrationTest :
|
||||
FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
val module = ModuleLoader.getModules().filterIsInstance<ModuleAttackCooldown>().firstOrNull()
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
fun <T> runSync(action: () -> T): T =
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable { action() }).get()
|
||||
}
|
||||
|
||||
fun currentAttackSpeed(player: Player): Double {
|
||||
val attackSpeedAttribute = checkNotNull(XAttribute.ATTACK_SPEED.get()) { "Missing attack speed attribute type" }
|
||||
val attribute = player.getAttribute(attackSpeedAttribute) ?: error("Missing attack speed attribute")
|
||||
return attribute.baseValue
|
||||
}
|
||||
|
||||
fun setModeset(
|
||||
player: Player,
|
||||
world: World,
|
||||
modeset: String,
|
||||
) {
|
||||
val data = PlayerStorage.getPlayerData(player.uniqueId)
|
||||
data.setModesetForWorld(world.uid, modeset)
|
||||
PlayerStorage.setPlayerData(player.uniqueId, data)
|
||||
}
|
||||
|
||||
fun fireJoin(player: Player) {
|
||||
Bukkit.getPluginManager().callEvent(PlayerJoinEvent(player, "test"))
|
||||
}
|
||||
|
||||
fun switchHotbar(
|
||||
player: Player,
|
||||
from: Int,
|
||||
to: Int,
|
||||
) {
|
||||
player.inventory.heldItemSlot = to
|
||||
Bukkit.getPluginManager().callEvent(PlayerItemHeldEvent(player, from, to))
|
||||
}
|
||||
|
||||
data class SpawnedPlayer(
|
||||
val fake: FakePlayer,
|
||||
val player: Player,
|
||||
)
|
||||
|
||||
fun spawnFake(world: World): SpawnedPlayer {
|
||||
lateinit var fake: FakePlayer
|
||||
lateinit var player: Player
|
||||
runSync {
|
||||
fake = FakePlayer(testPlugin)
|
||||
fake.spawn(Location(world, 0.0, 100.0, 0.0, 0f, 0f))
|
||||
player = checkNotNull(Bukkit.getPlayer(fake.uuid))
|
||||
player.inventory.clear()
|
||||
player.inventory.heldItemSlot = 0
|
||||
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
|
||||
setModeset(player, world, "old")
|
||||
}
|
||||
return SpawnedPlayer(fake, player)
|
||||
}
|
||||
|
||||
fun cleanup(spawnedPlayer: SpawnedPlayer) {
|
||||
runSync { spawnedPlayer.fake.removePlayer() }
|
||||
}
|
||||
|
||||
suspend fun waitForPossibleDeferredWork() {
|
||||
delay(2 * 50L)
|
||||
}
|
||||
|
||||
suspend fun withAttackCooldownConfig(
|
||||
genericAttackSpeed: Double,
|
||||
heldItemAttackSpeeds: Map<String, Double>,
|
||||
block: suspend () -> Unit,
|
||||
) {
|
||||
val disabledModules = ocm.config.getStringList("disabled_modules")
|
||||
val alwaysEnabledModules = ocm.config.getStringList("always_enabled_modules")
|
||||
val modesetsSection = ocm.config.getConfigurationSection("modesets") ?: error("Missing 'modesets' section")
|
||||
val modesetSnapshot =
|
||||
modesetsSection.getKeys(false).associateWith { key ->
|
||||
ocm.config.getStringList("modesets.$key")
|
||||
}
|
||||
val genericSnapshot = ocm.config.get("disable-attack-cooldown.generic-attack-speed")
|
||||
val heldItemSnapshot =
|
||||
ocm.config.getConfigurationSection("disable-attack-cooldown.held-item-attack-speeds")?.getValues(false)
|
||||
?: emptyMap<String, Any?>()
|
||||
|
||||
fun reloadAll() {
|
||||
ocm.saveConfig()
|
||||
Config.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
module?.reload()
|
||||
}
|
||||
|
||||
try {
|
||||
ocm.config.set("disable-attack-cooldown.generic-attack-speed", genericAttackSpeed)
|
||||
ocm.config.set("disable-attack-cooldown.held-item-attack-speeds", null)
|
||||
heldItemAttackSpeeds.forEach { (key, value) ->
|
||||
ocm.config.set("disable-attack-cooldown.held-item-attack-speeds.$key", value)
|
||||
}
|
||||
|
||||
ocm.config.set("disabled_modules", disabledModules.filterNot { it == "disable-attack-cooldown" })
|
||||
ocm.config.set("always_enabled_modules", alwaysEnabledModules.filterNot { it == "disable-attack-cooldown" })
|
||||
|
||||
val oldModeset =
|
||||
ocm.config.getStringList("modesets.old").toMutableList().apply {
|
||||
if (!contains("disable-attack-cooldown")) add("disable-attack-cooldown")
|
||||
}
|
||||
val newModeset =
|
||||
ocm.config.getStringList("modesets.new").toMutableList().apply {
|
||||
remove("disable-attack-cooldown")
|
||||
}
|
||||
ocm.config.set("modesets.old", oldModeset)
|
||||
ocm.config.set("modesets.new", newModeset)
|
||||
|
||||
reloadAll()
|
||||
block()
|
||||
} finally {
|
||||
ocm.config.set("disabled_modules", disabledModules)
|
||||
ocm.config.set("always_enabled_modules", alwaysEnabledModules)
|
||||
modesetSnapshot.forEach { (key, list) -> ocm.config.set("modesets.$key", list) }
|
||||
ocm.config.set("disable-attack-cooldown.generic-attack-speed", genericSnapshot)
|
||||
ocm.config.set("disable-attack-cooldown.held-item-attack-speeds", null)
|
||||
heldItemSnapshot.forEach { (key, value) ->
|
||||
ocm.config.set("disable-attack-cooldown.held-item-attack-speeds.$key", value)
|
||||
}
|
||||
reloadAll()
|
||||
}
|
||||
}
|
||||
|
||||
test("applies configured held-item attack speeds and falls back to the generic value on hotbar switch") {
|
||||
withAttackCooldownConfig(
|
||||
genericAttackSpeed = 12.0,
|
||||
heldItemAttackSpeeds = mapOf("IRON_SWORD" to 19.0),
|
||||
) {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val spawned = spawnFake(world)
|
||||
|
||||
try {
|
||||
runSync {
|
||||
spawned.player.inventory.setItem(0, ItemStack(Material.IRON_SWORD))
|
||||
spawned.player.inventory.setItem(1, ItemStack(Material.STICK))
|
||||
spawned.player.inventory.heldItemSlot = 0
|
||||
fireJoin(spawned.player)
|
||||
}
|
||||
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
|
||||
|
||||
runSync { switchHotbar(spawned.player, from = 0, to = 1) }
|
||||
runSync { currentAttackSpeed(spawned.player) } shouldBe (12.0 plusOrMinus 0.01)
|
||||
} finally {
|
||||
cleanup(spawned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("materials without an explicit held-item entry use disable-attack-cooldown.generic-attack-speed") {
|
||||
withAttackCooldownConfig(
|
||||
genericAttackSpeed = 13.0,
|
||||
heldItemAttackSpeeds = mapOf("IRON_SWORD" to 19.0),
|
||||
) {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val spawned = spawnFake(world)
|
||||
|
||||
try {
|
||||
runSync {
|
||||
spawned.player.inventory.setItemInMainHand(ItemStack(Material.STICK))
|
||||
fireJoin(spawned.player)
|
||||
}
|
||||
runSync { currentAttackSpeed(spawned.player) } shouldBe (13.0 plusOrMinus 0.01)
|
||||
} finally {
|
||||
cleanup(spawned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("world and modeset transitions restore vanilla 4.0 when disabled and reapply the held-item target when re-enabled") {
|
||||
withAttackCooldownConfig(
|
||||
genericAttackSpeed = 12.0,
|
||||
heldItemAttackSpeeds = mapOf("IRON_SWORD" to 19.0),
|
||||
) {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val otherWorld = checkNotNull(Bukkit.getWorld("world_nether"))
|
||||
val spawned = spawnFake(world)
|
||||
|
||||
try {
|
||||
runSync {
|
||||
spawned.player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))
|
||||
setModeset(spawned.player, world, "old")
|
||||
setModeset(spawned.player, otherWorld, "new")
|
||||
fireJoin(spawned.player)
|
||||
}
|
||||
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
|
||||
|
||||
runSync {
|
||||
setModeset(spawned.player, world, "new")
|
||||
module?.onModesetChange(spawned.player)
|
||||
}
|
||||
runSync { currentAttackSpeed(spawned.player) } shouldBe (4.0 plusOrMinus 0.01)
|
||||
|
||||
runSync {
|
||||
setModeset(spawned.player, world, "old")
|
||||
module?.onModesetChange(spawned.player)
|
||||
}
|
||||
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
|
||||
|
||||
var currentPlayer = spawned.player
|
||||
runSync {
|
||||
spawned.fake.teleport(Location(otherWorld, 0.0, 100.0, 0.0, 0f, 0f)) shouldBe true
|
||||
currentPlayer = spawned.fake.requireBukkitPlayer()
|
||||
Bukkit.getPluginManager().callEvent(PlayerChangedWorldEvent(currentPlayer, world))
|
||||
}
|
||||
runSync { currentAttackSpeed(currentPlayer) } shouldBe (4.0 plusOrMinus 0.01)
|
||||
|
||||
runSync {
|
||||
spawned.fake.teleport(Location(world, 0.0, 100.0, 0.0, 0f, 0f)) shouldBe true
|
||||
currentPlayer = spawned.fake.requireBukkitPlayer()
|
||||
Bukkit.getPluginManager().callEvent(PlayerChangedWorldEvent(currentPlayer, otherWorld))
|
||||
}
|
||||
runSync { currentAttackSpeed(currentPlayer) } shouldBe (19.0 plusOrMinus 0.01)
|
||||
} finally {
|
||||
cleanup(spawned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("user-added material keys are accepted when the running server recognises the material") {
|
||||
val material = Material.matchMaterial("MACE") ?: return@test
|
||||
|
||||
withAttackCooldownConfig(
|
||||
genericAttackSpeed = 12.0,
|
||||
heldItemAttackSpeeds = mapOf(material.name to 7.0),
|
||||
) {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val spawned = spawnFake(world)
|
||||
|
||||
try {
|
||||
runSync {
|
||||
spawned.player.inventory.setItemInMainHand(ItemStack(material))
|
||||
fireJoin(spawned.player)
|
||||
}
|
||||
runSync { currentAttackSpeed(spawned.player) } shouldBe (7.0 plusOrMinus 0.01)
|
||||
} finally {
|
||||
cleanup(spawned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("main-hand attack speed follows hand swaps and uses the newly held item") {
|
||||
withAttackCooldownConfig(
|
||||
genericAttackSpeed = 12.0,
|
||||
heldItemAttackSpeeds = mapOf("IRON_SWORD" to 19.0),
|
||||
) {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val spawned = spawnFake(world)
|
||||
|
||||
try {
|
||||
runSync {
|
||||
spawned.player.inventory.setItemInMainHand(ItemStack(Material.STICK))
|
||||
spawned.player.inventory.setItemInOffHand(ItemStack(Material.IRON_SWORD))
|
||||
fireJoin(spawned.player)
|
||||
}
|
||||
runSync { currentAttackSpeed(spawned.player) } shouldBe (12.0 plusOrMinus 0.01)
|
||||
|
||||
runSync {
|
||||
val swap =
|
||||
PlayerSwapHandItemsEvent(
|
||||
spawned.player,
|
||||
spawned.player.inventory.itemInMainHand,
|
||||
spawned.player.inventory.itemInOffHand,
|
||||
)
|
||||
Bukkit.getPluginManager().callEvent(swap)
|
||||
spawned.player.inventory.setItemInMainHand(swap.offHandItem)
|
||||
spawned.player.inventory.setItemInOffHand(swap.mainHandItem)
|
||||
}
|
||||
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
|
||||
} finally {
|
||||
cleanup(spawned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("cancelled hotbar changes keep attack speed tied to the actually held item") {
|
||||
withAttackCooldownConfig(
|
||||
genericAttackSpeed = 12.0,
|
||||
heldItemAttackSpeeds = mapOf("IRON_SWORD" to 19.0),
|
||||
) {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val spawned = spawnFake(world)
|
||||
val canceller =
|
||||
object : Listener {
|
||||
@EventHandler(priority = EventPriority.LOWEST)
|
||||
fun onHeld(event: PlayerItemHeldEvent) {
|
||||
if (event.player.uniqueId == spawned.player.uniqueId) {
|
||||
event.isCancelled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runSync {
|
||||
Bukkit.getPluginManager().registerEvents(canceller, testPlugin)
|
||||
spawned.player.inventory.setItem(0, ItemStack(Material.IRON_SWORD))
|
||||
spawned.player.inventory.setItem(1, ItemStack(Material.STICK))
|
||||
spawned.player.inventory.heldItemSlot = 0
|
||||
fireJoin(spawned.player)
|
||||
}
|
||||
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
|
||||
|
||||
runSync {
|
||||
val event = PlayerItemHeldEvent(spawned.player, 0, 1)
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
}
|
||||
|
||||
runSync { spawned.player.inventory.heldItemSlot } shouldBe 0
|
||||
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
|
||||
} finally {
|
||||
runSync { HandlerList.unregisterAll(canceller) }
|
||||
cleanup(spawned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("cancelled hand swaps keep attack speed tied to the actual main-hand item") {
|
||||
withAttackCooldownConfig(
|
||||
genericAttackSpeed = 12.0,
|
||||
heldItemAttackSpeeds = mapOf("IRON_SWORD" to 19.0),
|
||||
) {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val spawned = spawnFake(world)
|
||||
val canceller =
|
||||
object : Listener {
|
||||
@EventHandler(priority = EventPriority.LOWEST)
|
||||
fun onSwap(event: PlayerSwapHandItemsEvent) {
|
||||
if (event.player.uniqueId == spawned.player.uniqueId) {
|
||||
event.isCancelled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runSync {
|
||||
Bukkit.getPluginManager().registerEvents(canceller, testPlugin)
|
||||
spawned.player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))
|
||||
spawned.player.inventory.setItemInOffHand(ItemStack(Material.STICK))
|
||||
fireJoin(spawned.player)
|
||||
}
|
||||
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
|
||||
|
||||
runSync {
|
||||
val event =
|
||||
PlayerSwapHandItemsEvent(
|
||||
spawned.player,
|
||||
spawned.player.inventory.itemInMainHand,
|
||||
spawned.player.inventory.itemInOffHand,
|
||||
)
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
}
|
||||
|
||||
runSync { spawned.player.inventory.itemInMainHand.type } shouldBe Material.IRON_SWORD
|
||||
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
|
||||
} finally {
|
||||
runSync { HandlerList.unregisterAll(canceller) }
|
||||
cleanup(spawned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("later inventory changes after a hotbar event do not trigger deferred attack-speed reconciliation") {
|
||||
withAttackCooldownConfig(
|
||||
genericAttackSpeed = 12.0,
|
||||
heldItemAttackSpeeds = mapOf("IRON_SWORD" to 19.0),
|
||||
) {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val spawned = spawnFake(world)
|
||||
|
||||
try {
|
||||
runSync {
|
||||
spawned.player.inventory.setItem(0, ItemStack(Material.IRON_SWORD))
|
||||
spawned.player.inventory.setItem(1, ItemStack(Material.STICK))
|
||||
spawned.player.inventory.heldItemSlot = 0
|
||||
fireJoin(spawned.player)
|
||||
}
|
||||
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
|
||||
|
||||
runSync {
|
||||
val event = PlayerItemHeldEvent(spawned.player, 0, 1)
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
spawned.player.inventory.heldItemSlot = 1
|
||||
spawned.player.inventory.heldItemSlot = 0
|
||||
}
|
||||
|
||||
waitForPossibleDeferredWork()
|
||||
|
||||
runSync { spawned.player.inventory.heldItemSlot } shouldBe 0
|
||||
runSync { spawned.player.inventory.itemInMainHand.type } shouldBe Material.IRON_SWORD
|
||||
runSync { currentAttackSpeed(spawned.player) } shouldBe (12.0 plusOrMinus 0.01)
|
||||
} finally {
|
||||
cleanup(spawned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("unchanged post-swap inventory keeps the swap-applied attack speed without deferred re-checking") {
|
||||
withAttackCooldownConfig(
|
||||
genericAttackSpeed = 12.0,
|
||||
heldItemAttackSpeeds = mapOf("IRON_SWORD" to 19.0),
|
||||
) {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val spawned = spawnFake(world)
|
||||
|
||||
try {
|
||||
runSync {
|
||||
spawned.player.inventory.setItemInMainHand(ItemStack(Material.STICK))
|
||||
spawned.player.inventory.setItemInOffHand(ItemStack(Material.IRON_SWORD))
|
||||
fireJoin(spawned.player)
|
||||
}
|
||||
runSync { currentAttackSpeed(spawned.player) } shouldBe (12.0 plusOrMinus 0.01)
|
||||
|
||||
runSync {
|
||||
val event =
|
||||
PlayerSwapHandItemsEvent(
|
||||
spawned.player,
|
||||
spawned.player.inventory.itemInMainHand,
|
||||
spawned.player.inventory.itemInOffHand,
|
||||
)
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
spawned.player.inventory.setItemInMainHand(event.offHandItem)
|
||||
spawned.player.inventory.setItemInOffHand(event.mainHandItem)
|
||||
}
|
||||
|
||||
waitForPossibleDeferredWork()
|
||||
|
||||
runSync { spawned.player.inventory.itemInMainHand.type } shouldBe Material.IRON_SWORD
|
||||
runSync { currentAttackSpeed(spawned.player) } shouldBe (19.0 plusOrMinus 0.01)
|
||||
} finally {
|
||||
cleanup(spawned)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.doubles.shouldBeGreaterThanOrEqual
|
||||
import io.kotest.matchers.doubles.shouldBeLessThanOrEqual
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.damage.AttackCooldownTracker
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
|
||||
import kotlinx.coroutines.delay
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class AttackCooldownTrackerIntegrationTest : FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
|
||||
fun <T> runSync(action: () -> T): T {
|
||||
return if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
|
||||
action()
|
||||
}).get()
|
||||
}
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
test("attack cooldown tracking is only active where required") {
|
||||
val isModern = Reflector.versionIsNewerOrEqualTo(1, 16, 0)
|
||||
|
||||
val uuid = runSync {
|
||||
val world = Bukkit.getWorld("world") ?: error("world not loaded")
|
||||
val location = Location(world, 0.0, 120.0, 0.0, 0f, 0f)
|
||||
val fp = FakePlayer(testPlugin)
|
||||
fp.spawn(location)
|
||||
fp.uuid
|
||||
}
|
||||
|
||||
try {
|
||||
// Let at least one tick elapse so any scheduled tracker has a chance to populate.
|
||||
delay(2 * 50L)
|
||||
|
||||
val last = runSync { AttackCooldownTracker.getLastCooldown(uuid) }
|
||||
if (isModern) {
|
||||
last shouldBe null
|
||||
} else {
|
||||
val value = (last ?: error("Expected a cached cooldown value on legacy servers")).toDouble()
|
||||
value.shouldBeGreaterThanOrEqual(0.0)
|
||||
value.shouldBeLessThanOrEqual(1.0)
|
||||
}
|
||||
} finally {
|
||||
// Ensure we remove the fake player regardless of assertions.
|
||||
runSync {
|
||||
val player = Bukkit.getPlayer(uuid)
|
||||
player?.let {
|
||||
// FakePlayer removal fires a quit event; this is enough to validate map cleanup on legacy servers.
|
||||
it.kickPlayer("test")
|
||||
}
|
||||
}
|
||||
delay(2 * 50L)
|
||||
|
||||
// Legacy servers: tracker should remove on quit. Modern servers: tracker should remain inactive and return null.
|
||||
val afterQuit = runSync { AttackCooldownTracker.getLastCooldown(uuid) }
|
||||
afterQuit shouldBe null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.doubles.shouldBeLessThan
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleAttackRange
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.entity.EntityType
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.entity.Zombie
|
||||
import org.bukkit.event.player.PlayerDropItemEvent
|
||||
import org.bukkit.event.player.PlayerItemHeldEvent
|
||||
import org.bukkit.event.player.PlayerSwapHandItemsEvent
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
/**
|
||||
* Behavioural reach tests for the attack-range module.
|
||||
*
|
||||
* The module extends melee reach; we assert a hit just outside vanilla reach succeeds
|
||||
* when enabled, but the same swing misses when disabled. A control swing inside vanilla
|
||||
* reach must hit in both cases.
|
||||
*/
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class AttackRangeIntegrationTest :
|
||||
FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
val attackRangeModule = ModuleLoader.getModules().filterIsInstance<ModuleAttackRange>().firstOrNull()
|
||||
val attackRangeApiAvailable = ModuleAttackRange.isAttackRangeApiAvailable()
|
||||
val applyMethod =
|
||||
attackRangeModule?.javaClass?.getDeclaredMethod("applyToHeld", Player::class.java)?.apply {
|
||||
isAccessible = true
|
||||
}
|
||||
|
||||
fun hasAttackRange(stack: ItemStack?): Boolean =
|
||||
try {
|
||||
if (stack == null) return false
|
||||
val dctClass = Class.forName("io.papermc.paper.datacomponent.DataComponentTypes")
|
||||
val typeField = dctClass.getField("ATTACK_RANGE")
|
||||
val type = typeField.get(null)
|
||||
val baseTypeClass = Class.forName("io.papermc.paper.datacomponent.DataComponentType")
|
||||
val getter = ItemStack::class.java.getMethod("getData", baseTypeClass)
|
||||
getter.invoke(stack, type) != null
|
||||
} catch (t: Throwable) {
|
||||
false
|
||||
}
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
fun runSync(action: () -> Unit) {
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit
|
||||
.getScheduler()
|
||||
.callSyncMethod(
|
||||
testPlugin,
|
||||
Callable {
|
||||
action()
|
||||
null
|
||||
},
|
||||
).get()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun withModuleState(
|
||||
enabled: Boolean,
|
||||
maxRange: Double = 6.0,
|
||||
margin: Double = 0.1,
|
||||
block: suspend () -> Unit,
|
||||
) {
|
||||
// Snapshot
|
||||
val disabledOrig = ocm.config.getStringList("disabled_modules").toMutableList()
|
||||
val alwaysOrig = ocm.config.getStringList("always_enabled_modules").toMutableList()
|
||||
val maxOrig = ocm.config.getDouble("attack-range.max-range")
|
||||
val marginOrig = ocm.config.getDouble("attack-range.hitbox-margin")
|
||||
|
||||
fun cachedSet(field: String): MutableSet<String> {
|
||||
val f = kernitus.plugin.OldCombatMechanics.utilities.Config::class.java.getDeclaredField(field)
|
||||
f.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return f.get(null) as MutableSet<String>
|
||||
}
|
||||
val cachedDisabledOrig = cachedSet("disabledModules").toSet()
|
||||
val cachedAlwaysOrig = cachedSet("alwaysEnabledModules").toSet()
|
||||
|
||||
try {
|
||||
// Update lists
|
||||
val disabled = disabledOrig.toMutableList()
|
||||
val always = alwaysOrig.toMutableList()
|
||||
if (enabled) {
|
||||
disabled.remove("attack-range")
|
||||
if (!always.contains("attack-range")) always.add("attack-range")
|
||||
} else {
|
||||
if (!disabled.contains("attack-range")) disabled.add("attack-range")
|
||||
always.remove("attack-range")
|
||||
}
|
||||
ocm.config.set("disabled_modules", disabled)
|
||||
ocm.config.set("always_enabled_modules", always)
|
||||
|
||||
cachedSet("disabledModules").apply {
|
||||
clear()
|
||||
addAll(disabled.map { it.lowercase() })
|
||||
}
|
||||
cachedSet("alwaysEnabledModules").apply {
|
||||
clear()
|
||||
addAll(always.map { it.lowercase() })
|
||||
}
|
||||
|
||||
// Config tweaks
|
||||
ocm.config.set("attack-range.max-range", maxRange)
|
||||
ocm.config.set("attack-range.hitbox-margin", margin)
|
||||
|
||||
attackRangeModule?.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
|
||||
block()
|
||||
} finally {
|
||||
// Restore
|
||||
ocm.config.set("disabled_modules", disabledOrig)
|
||||
ocm.config.set("always_enabled_modules", alwaysOrig)
|
||||
ocm.config.set("attack-range.max-range", maxOrig)
|
||||
ocm.config.set("attack-range.hitbox-margin", marginOrig)
|
||||
|
||||
cachedSet("disabledModules").apply {
|
||||
clear()
|
||||
addAll(cachedDisabledOrig)
|
||||
}
|
||||
cachedSet("alwaysEnabledModules").apply {
|
||||
clear()
|
||||
addAll(cachedAlwaysOrig)
|
||||
}
|
||||
|
||||
attackRangeModule?.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
}
|
||||
}
|
||||
|
||||
data class Actors(
|
||||
val fake: FakePlayer,
|
||||
val player: Player,
|
||||
val zombie: Zombie,
|
||||
)
|
||||
|
||||
fun spawnActors(): Actors {
|
||||
lateinit var fake: FakePlayer
|
||||
lateinit var player: Player
|
||||
lateinit var zombie: Zombie
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
fake = FakePlayer(testPlugin)
|
||||
fake.spawn(Location(world, 0.0, 100.0, 0.0))
|
||||
player = checkNotNull(Bukkit.getPlayer(fake.uuid))
|
||||
player.gameMode = GameMode.SURVIVAL
|
||||
player.isInvulnerable = false
|
||||
player.inventory.clear()
|
||||
player.inventory.setItemInMainHand(ItemStack(Material.IRON_SWORD))
|
||||
|
||||
zombie = world.spawnEntity(Location(world, 0.0, 100.0, 0.0), EntityType.ZOMBIE) as Zombie
|
||||
zombie.health = zombie.maxHealth
|
||||
}
|
||||
return Actors(fake, player, zombie)
|
||||
}
|
||||
|
||||
fun cleanup(actors: Actors) {
|
||||
runSync {
|
||||
actors.zombie.remove()
|
||||
}
|
||||
runSync { actors.fake.removePlayer() }
|
||||
}
|
||||
|
||||
fun faceEntity(
|
||||
player: Player,
|
||||
target: org.bukkit.entity.Entity,
|
||||
) {
|
||||
val eye = player.eyeLocation
|
||||
val tgt = target.location.clone().add(0.0, target.height / 2.0, 0.0)
|
||||
val dir = tgt.toVector().subtract(eye.toVector())
|
||||
val yaw = Math.toDegrees(Math.atan2(-dir.x, dir.z)).toFloat()
|
||||
val pitch = Math.toDegrees(-Math.atan2(dir.y, Math.hypot(dir.x, dir.z))).toFloat()
|
||||
val newLoc = player.location.clone()
|
||||
newLoc.yaw = yaw
|
||||
newLoc.pitch = pitch
|
||||
player.teleport(newLoc)
|
||||
}
|
||||
|
||||
fun swingAt(
|
||||
zombie: Zombie,
|
||||
player: Player,
|
||||
) {
|
||||
runSync {
|
||||
faceEntity(player, zombie)
|
||||
player.attack(zombie)
|
||||
}
|
||||
}
|
||||
|
||||
test("extended reach hits just outside vanilla range when module enabled (AttackRange API available)") {
|
||||
if (!attackRangeApiAvailable) return@test
|
||||
if (attackRangeModule == null) return@test
|
||||
|
||||
withModuleState(enabled = true, maxRange = 5.0, margin = 0.1) {
|
||||
val actors = spawnActors()
|
||||
val (_, player, zombie) = actors
|
||||
runSync { applyMethod?.invoke(attackRangeModule, player) }
|
||||
|
||||
runSync {
|
||||
zombie.noDamageTicks = 0
|
||||
zombie.health = zombie.maxHealth
|
||||
zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5))
|
||||
}
|
||||
|
||||
var startHealth = zombie.health
|
||||
swingAt(zombie, player)
|
||||
|
||||
runSync { zombie.health shouldBeLessThan startHealth }
|
||||
|
||||
cleanup(actors)
|
||||
}
|
||||
}
|
||||
|
||||
test("reach boost is removed after disabling while holding the same item (AttackRange API available)") {
|
||||
if (!attackRangeApiAvailable) return@test
|
||||
if (attackRangeModule == null) return@test
|
||||
|
||||
val actors = spawnActors()
|
||||
val (_, player, zombie) = actors
|
||||
|
||||
withModuleState(enabled = true, maxRange = 5.5) {
|
||||
runSync { applyMethod?.invoke(attackRangeModule, player) }
|
||||
runSync { zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5)) }
|
||||
var start = zombie.health
|
||||
swingAt(zombie, player) // should hit with extended reach
|
||||
runSync { zombie.health shouldBeLessThan start }
|
||||
|
||||
withModuleState(enabled = false) {
|
||||
runSync {
|
||||
// trigger hand re-evaluation
|
||||
Bukkit.getPluginManager().callEvent(
|
||||
PlayerItemHeldEvent(player, player.inventory.heldItemSlot, player.inventory.heldItemSlot),
|
||||
)
|
||||
zombie.health = zombie.maxHealth
|
||||
zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5))
|
||||
}
|
||||
start = zombie.health
|
||||
swingAt(zombie, player) // should now miss
|
||||
runSync { zombie.health shouldBe start }
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(actors)
|
||||
}
|
||||
|
||||
test("dropped items do not retain reach boost (AttackRange API available)") {
|
||||
if (!attackRangeApiAvailable) return@test
|
||||
if (attackRangeModule == null) return@test
|
||||
|
||||
val actors = spawnActors()
|
||||
val (_, player, zombie) = actors
|
||||
|
||||
withModuleState(enabled = true) {
|
||||
runSync { applyMethod?.invoke(attackRangeModule, player) }
|
||||
runSync { zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5)) }
|
||||
var start = zombie.health
|
||||
swingAt(zombie, player) // extended reach hit
|
||||
runSync { zombie.health shouldBeLessThan start }
|
||||
|
||||
val dropped: ItemStack =
|
||||
run {
|
||||
var item: ItemStack? = null
|
||||
runSync {
|
||||
val drop = player.world.dropItem(player.location, player.inventory.itemInMainHand.clone())
|
||||
Bukkit.getPluginManager().callEvent(PlayerDropItemEvent(player, drop))
|
||||
item = drop.itemStack
|
||||
drop.remove()
|
||||
}
|
||||
item!!
|
||||
}
|
||||
|
||||
withModuleState(enabled = false) {
|
||||
runSync {
|
||||
player.inventory.setItemInMainHand(dropped)
|
||||
Bukkit.getPluginManager().callEvent(
|
||||
PlayerItemHeldEvent(player, player.inventory.heldItemSlot, player.inventory.heldItemSlot),
|
||||
)
|
||||
zombie.health = zombie.maxHealth
|
||||
zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5))
|
||||
}
|
||||
start = zombie.health
|
||||
swingAt(zombie, player) // should miss because drop cleaned component
|
||||
runSync { zombie.health shouldBe start }
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(actors)
|
||||
}
|
||||
|
||||
test("extended reach does not apply when module disabled; close swing still hits (AttackRange API available)") {
|
||||
if (!attackRangeApiAvailable) return@test
|
||||
if (attackRangeModule == null) return@test
|
||||
|
||||
withModuleState(enabled = false) {
|
||||
val actors = spawnActors()
|
||||
val (_, player, zombie) = actors
|
||||
|
||||
// Far swing should miss when module disabled
|
||||
runSync {
|
||||
zombie.noDamageTicks = 0
|
||||
zombie.health = zombie.maxHealth
|
||||
zombie.teleport(player.location.clone().add(0.0, 0.0, 5.5))
|
||||
}
|
||||
var healthAfterFar = zombie.health
|
||||
swingAt(zombie, player)
|
||||
runSync { healthAfterFar = zombie.health }
|
||||
|
||||
// Control: move inside vanilla reach and ensure it hits
|
||||
runSync {
|
||||
zombie.noDamageTicks = 0
|
||||
zombie.teleport(player.location.clone().add(0.0, 0.0, 2.5))
|
||||
}
|
||||
swingAt(zombie, player)
|
||||
|
||||
runSync {
|
||||
zombie.health shouldBeLessThan healthAfterFar
|
||||
}
|
||||
|
||||
cleanup(actors)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import com.cryptomorin.xseries.XAttribute
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage
|
||||
import org.bukkit.attribute.AttributeModifier
|
||||
import org.bukkit.inventory.EquipmentSlot
|
||||
import org.bukkit.attribute.Attribute
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.inventory.meta.ItemMeta
|
||||
import com.google.common.collect.HashMultimap
|
||||
import com.google.common.collect.Multimap
|
||||
import java.util.UUID
|
||||
|
||||
fun createAttributeModifier(
|
||||
name: String,
|
||||
amount: Double,
|
||||
operation: AttributeModifier.Operation,
|
||||
slot: EquipmentSlot? = null,
|
||||
uuid: UUID = UUID.randomUUID()
|
||||
): AttributeModifier {
|
||||
// Use the most specific constructor available at runtime.
|
||||
if (slot != null) {
|
||||
try {
|
||||
@Suppress("DEPRECATION")
|
||||
return AttributeModifier(uuid, name, amount, operation, slot)
|
||||
} catch (e: NoSuchMethodError) {
|
||||
// Fall back to the older signatures below.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@Suppress("DEPRECATION")
|
||||
return AttributeModifier(uuid, name, amount, operation)
|
||||
} catch (e: NoSuchMethodError) {
|
||||
@Suppress("DEPRECATION")
|
||||
return AttributeModifier(name, amount, operation)
|
||||
}
|
||||
}
|
||||
|
||||
fun addAttributeModifierCompat(meta: ItemMeta, attribute: Attribute, modifier: AttributeModifier) {
|
||||
try {
|
||||
meta.addAttributeModifier(attribute, modifier)
|
||||
return
|
||||
} catch (e: NoSuchMethodError) {
|
||||
// Older APIs do not expose addAttributeModifier on ItemMeta.
|
||||
}
|
||||
|
||||
try {
|
||||
val multimap = HashMultimap.create<Attribute, AttributeModifier>()
|
||||
multimap.put(attribute, modifier)
|
||||
meta.setAttributeModifiers(multimap)
|
||||
} catch (e: NoSuchMethodError) {
|
||||
// Attribute modifiers are not supported on this API version.
|
||||
}
|
||||
}
|
||||
|
||||
fun getDefaultAttributeModifiersCompat(
|
||||
item: ItemStack,
|
||||
slot: EquipmentSlot,
|
||||
attribute: Attribute
|
||||
): Collection<AttributeModifier> {
|
||||
try {
|
||||
return item.type.getDefaultAttributeModifiers(slot)[attribute] ?: emptySet()
|
||||
} catch (e: NoSuchMethodError) {
|
||||
// Fall back to older Material APIs if present.
|
||||
}
|
||||
|
||||
val modifiers = try {
|
||||
val method = item.type.javaClass.getMethod("getAttributeModifiers", EquipmentSlot::class.java)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val multimap = method.invoke(item.type, slot) as Multimap<Attribute, AttributeModifier>
|
||||
multimap.get(attribute) ?: emptySet()
|
||||
} catch (e: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
if (modifiers.isNotEmpty()) {
|
||||
return modifiers
|
||||
}
|
||||
|
||||
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()
|
||||
if (attackDamageAttribute != null && attribute == attackDamageAttribute && slot == EquipmentSlot.HAND) {
|
||||
val fallbackDamage = NewWeaponDamage.getDamageOrNull(item.type) ?: return emptySet()
|
||||
val amount = fallbackDamage.toDouble() - 1.0
|
||||
val fallbackModifier = createAttributeModifier(
|
||||
name = "ocm-fallback-damage",
|
||||
amount = amount,
|
||||
operation = AttributeModifier.Operation.ADD_NUMBER,
|
||||
slot = slot
|
||||
)
|
||||
return setOf(fallbackModifier)
|
||||
}
|
||||
|
||||
return emptySet()
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.core.test.TestScope
|
||||
import io.kotest.matchers.booleans.shouldBeTrue
|
||||
import io.kotest.matchers.doubles.shouldBeLessThanOrEqual
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleChorusFruit
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.CompatibilityCapabilities
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.block.BlockFace
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.player.PlayerTeleportEvent
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class ChorusFruitIntegrationTest : FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
val chorusModule = ModuleLoader.getModules()
|
||||
.filterIsInstance<ModuleChorusFruit>()
|
||||
.firstOrNull() ?: error("ModuleChorusFruit not registered")
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
fun runSync(action: () -> Unit) {
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
|
||||
action()
|
||||
null
|
||||
}).get()
|
||||
}
|
||||
}
|
||||
|
||||
fun preparePlatform() {
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
// Solid floor at y=100 and clear air above it in a 25x25 area around the origin
|
||||
for (x in -12..12) {
|
||||
for (z in -12..12) {
|
||||
world.getBlockAt(x, 100, z).type = Material.STONE
|
||||
for (y in 101..105) {
|
||||
world.getBlockAt(x, y, z).type = Material.AIR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearPlatform() {
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
for (x in -12..12) {
|
||||
for (z in -12..12) {
|
||||
for (y in 99..105) {
|
||||
world.getBlockAt(x, y, z).type = Material.AIR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun TestScope.withChorusConfig(distance: Double, block: suspend TestScope.() -> Unit) {
|
||||
val enabled = ocm.config.getBoolean("chorus-fruit.enabled")
|
||||
val maxDistance = ocm.config.getDouble("chorus-fruit.max-teleportation-distance")
|
||||
val preventEating = ocm.config.getBoolean("chorus-fruit.prevent-eating")
|
||||
val hungerValue = ocm.config.getInt("chorus-fruit.hunger-value")
|
||||
val saturationValue = ocm.config.getDouble("chorus-fruit.saturation-value")
|
||||
|
||||
try {
|
||||
ocm.config.set("chorus-fruit.enabled", true)
|
||||
ocm.config.set("chorus-fruit.max-teleportation-distance", distance)
|
||||
ocm.config.set("chorus-fruit.prevent-eating", false)
|
||||
ocm.config.set("chorus-fruit.hunger-value", hungerValue)
|
||||
ocm.config.set("chorus-fruit.saturation-value", saturationValue)
|
||||
chorusModule.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
block()
|
||||
} finally {
|
||||
clearPlatform()
|
||||
ocm.config.set("chorus-fruit.enabled", enabled)
|
||||
ocm.config.set("chorus-fruit.max-teleportation-distance", maxDistance)
|
||||
ocm.config.set("chorus-fruit.prevent-eating", preventEating)
|
||||
ocm.config.set("chorus-fruit.hunger-value", hungerValue)
|
||||
ocm.config.set("chorus-fruit.saturation-value", saturationValue)
|
||||
chorusModule.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
}
|
||||
}
|
||||
|
||||
fun Location.isSafe(): Boolean {
|
||||
val feet = block
|
||||
val head = feet.getRelative(BlockFace.UP)
|
||||
val below = feet.getRelative(BlockFace.DOWN)
|
||||
val feetPassable = CompatibilityCapabilities.isBlockPassable(feet)
|
||||
val headPassable = CompatibilityCapabilities.isBlockPassable(head)
|
||||
return feetPassable && headPassable && below.type.isSolid
|
||||
}
|
||||
|
||||
suspend fun withFakePlayer(origin: Location, block: suspend (Player) -> Unit) {
|
||||
lateinit var fake: FakePlayer
|
||||
lateinit var player: Player
|
||||
|
||||
runSync {
|
||||
fake = FakePlayer(testPlugin)
|
||||
fake.spawn(origin)
|
||||
player = checkNotNull(Bukkit.getPlayer(fake.uuid))
|
||||
player.gameMode = GameMode.SURVIVAL
|
||||
player.isInvulnerable = false
|
||||
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
|
||||
val data = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
|
||||
data.setModesetForWorld(player.world.uid, "old")
|
||||
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, data)
|
||||
}
|
||||
|
||||
try {
|
||||
block(player)
|
||||
} finally {
|
||||
runSync {
|
||||
fake.removePlayer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("chorus fruit custom distance teleports to a safe spot") {
|
||||
preparePlatform()
|
||||
withChorusConfig(distance = 1.0) {
|
||||
val origin = Location(checkNotNull(Bukkit.getWorld("world")), 0.5, 101.0, 0.5)
|
||||
withFakePlayer(origin) { player ->
|
||||
var result: Location? = null
|
||||
runSync {
|
||||
val event = PlayerTeleportEvent(
|
||||
player,
|
||||
player.location,
|
||||
player.location.clone(),
|
||||
PlayerTeleportEvent.TeleportCause.CHORUS_FRUIT
|
||||
)
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
result = event.to
|
||||
}
|
||||
|
||||
val target = result ?: error("Teleport target was null")
|
||||
// Horizontal displacement should respect the configured radius
|
||||
kotlin.math.abs(target.x - origin.x) shouldBeLessThanOrEqual 1.0
|
||||
kotlin.math.abs(target.z - origin.z) shouldBeLessThanOrEqual 1.0
|
||||
// Y stays within the search band (clamped to world height)
|
||||
kotlin.math.abs(target.y - origin.y) shouldBeLessThanOrEqual 1.0
|
||||
target.isSafe().shouldBeTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldContain
|
||||
import io.kotest.matchers.collections.shouldNotContain
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.Config
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.configuration.file.YamlConfiguration
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.io.File
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class ConfigMigrationIntegrationTest : FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
fun runSync(action: () -> Unit) {
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
|
||||
action()
|
||||
null
|
||||
}).get()
|
||||
}
|
||||
}
|
||||
|
||||
fun withConfigFile(block: () -> Unit) {
|
||||
val dataFolder = ocm.dataFolder
|
||||
val configFile = File(dataFolder, "config.yml")
|
||||
val backupFile = File(dataFolder, "config-backup.yml")
|
||||
val originalConfig = if (configFile.exists()) configFile.readText() else ""
|
||||
val hadBackup = backupFile.exists()
|
||||
val originalBackup = if (hadBackup) backupFile.readText() else null
|
||||
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
if (!configFile.parentFile.exists()) {
|
||||
configFile.parentFile.mkdirs()
|
||||
}
|
||||
configFile.writeText(originalConfig)
|
||||
if (hadBackup) {
|
||||
backupFile.writeText(originalBackup ?: "")
|
||||
} else if (backupFile.exists()) {
|
||||
backupFile.delete()
|
||||
}
|
||||
ocm.reloadConfig()
|
||||
Config.reload()
|
||||
}
|
||||
}
|
||||
|
||||
test("config upgrade migrates module buckets and preserves modesets") {
|
||||
runSync {
|
||||
withConfigFile {
|
||||
val configFile = File(ocm.dataFolder, "config.yml")
|
||||
val oldConfig = YamlConfiguration.loadConfiguration(configFile)
|
||||
val currentVersion = oldConfig.getInt("config-version")
|
||||
val oldVersion = currentVersion - 1
|
||||
|
||||
oldConfig.set("config-version", oldVersion)
|
||||
oldConfig.set("force-below-1-18-1-config-upgrade", true)
|
||||
|
||||
oldConfig.set(
|
||||
"modesets",
|
||||
linkedMapOf(
|
||||
"custom" to listOf("disable-offhand"),
|
||||
"alt" to listOf("old-golden-apples")
|
||||
)
|
||||
)
|
||||
oldConfig.set("worlds.__default__", null)
|
||||
oldConfig.set("worlds.world", listOf("custom", "alt"))
|
||||
oldConfig.set("worlds.nether", listOf("alt"))
|
||||
|
||||
oldConfig.set("disable-offhand.enabled", false)
|
||||
oldConfig.set("old-golden-apples.enabled", true)
|
||||
oldConfig.set("old-potion-effects.enabled", true)
|
||||
oldConfig.set("disable-attack-cooldown.enabled", false)
|
||||
|
||||
oldConfig.save(configFile)
|
||||
|
||||
Config.reload()
|
||||
|
||||
val upgradedConfig = ocm.config
|
||||
upgradedConfig.getInt("config-version") shouldBe currentVersion
|
||||
|
||||
val alwaysEnabled = upgradedConfig.getStringList("always_enabled_modules")
|
||||
val disabledModules = upgradedConfig.getStringList("disabled_modules")
|
||||
|
||||
alwaysEnabled.shouldContain("old-potion-effects")
|
||||
disabledModules.shouldContain("disable-offhand")
|
||||
disabledModules.shouldContain("disable-attack-cooldown")
|
||||
disabledModules.shouldNotContain("old-golden-apples")
|
||||
|
||||
val modesetsSection = upgradedConfig.getConfigurationSection("modesets")
|
||||
?: error("Modesets section missing after migration")
|
||||
modesetsSection.getKeys(false).shouldContain("custom")
|
||||
modesetsSection.getKeys(false).shouldContain("alt")
|
||||
|
||||
upgradedConfig.getStringList("modesets.custom").shouldNotContain("disable-offhand")
|
||||
upgradedConfig.getStringList("modesets.alt").shouldContain("old-golden-apples")
|
||||
upgradedConfig.getStringList("worlds.world") shouldBe listOf("custom", "alt")
|
||||
upgradedConfig.getStringList("worlds.nether") shouldBe listOf("alt")
|
||||
upgradedConfig.getStringList("worlds.__default__") shouldBe listOf("custom", "alt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("config upgrade preserves existing default world modesets") {
|
||||
runSync {
|
||||
withConfigFile {
|
||||
val configFile = File(ocm.dataFolder, "config.yml")
|
||||
val oldConfig = YamlConfiguration.loadConfiguration(configFile)
|
||||
val currentVersion = oldConfig.getInt("config-version")
|
||||
val oldVersion = currentVersion - 1
|
||||
|
||||
oldConfig.set("config-version", oldVersion)
|
||||
oldConfig.set("force-below-1-18-1-config-upgrade", true)
|
||||
|
||||
oldConfig.set(
|
||||
"modesets",
|
||||
linkedMapOf(
|
||||
"custom" to listOf("disable-offhand"),
|
||||
"alt" to listOf("old-golden-apples")
|
||||
)
|
||||
)
|
||||
oldConfig.set("worlds.__default__", listOf("legacy-default"))
|
||||
oldConfig.set("worlds.world", listOf("custom"))
|
||||
|
||||
oldConfig.save(configFile)
|
||||
|
||||
Config.reload()
|
||||
|
||||
val upgradedConfig = ocm.config
|
||||
upgradedConfig.getInt("config-version") shouldBe currentVersion
|
||||
upgradedConfig.getStringList("worlds.__default__") shouldBe listOf("legacy-default")
|
||||
upgradedConfig.getStringList("worlds.world") shouldBe listOf("custom")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import com.cryptomorin.xseries.XMaterial
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.StringSpec
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class CopperToolsIntegrationTest : StringSpec({
|
||||
val plugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
extension(MainThreadDispatcherExtension(plugin))
|
||||
val copperMaterials = listOf(
|
||||
XMaterial.COPPER_SWORD,
|
||||
XMaterial.COPPER_AXE,
|
||||
XMaterial.COPPER_PICKAXE,
|
||||
XMaterial.COPPER_SHOVEL,
|
||||
XMaterial.COPPER_HOE
|
||||
).mapNotNull { xmat ->
|
||||
val material = runCatching { Material.valueOf(xmat.name) }.getOrNull()
|
||||
material?.let { xmat to it }
|
||||
}
|
||||
|
||||
if (copperMaterials.isEmpty()) {
|
||||
"copper tools not present on this version" {
|
||||
plugin.logger.info("Copper tools not present on this version; skipping copper damage checks.")
|
||||
}
|
||||
} else {
|
||||
copperMaterials.forEach { (xmat, material) ->
|
||||
"copper tool damage is configurable for ${xmat.name}" {
|
||||
val damage = WeaponDamages.getDamage(material)
|
||||
if (damage < 0.0) {
|
||||
throw AssertionError("Expected ${xmat.name} to be present in old-tool-damage.damages (config.yml)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.core.test.TestScope
|
||||
import io.kotest.matchers.doubles.plusOrMinus
|
||||
import io.kotest.matchers.doubles.shouldBeExactly
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleOldToolDamage
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.CompatibilityCapabilities
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.entity.Trident
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.EventPriority
|
||||
import org.bukkit.event.HandlerList
|
||||
import org.bukkit.event.Listener
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent
|
||||
import org.bukkit.event.entity.EntityDamageEvent
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class CustomWeaponDamageIntegrationTest : FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
val toolDamageModule = ModuleLoader.getModules()
|
||||
.filterIsInstance<ModuleOldToolDamage>()
|
||||
.firstOrNull() ?: error("ModuleOldToolDamage not registered")
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
fun runSync(action: () -> Unit) {
|
||||
if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
|
||||
action()
|
||||
null
|
||||
}).get()
|
||||
}
|
||||
|
||||
suspend fun TestScope.withWeaponConfig(
|
||||
tridentMelee: Double?,
|
||||
tridentThrown: Double?,
|
||||
mace: Double?,
|
||||
block: suspend TestScope.() -> Unit
|
||||
) {
|
||||
val snapshot = ocm.config.getConfigurationSection("old-tool-damage.damages")?.getValues(false) ?: emptyMap<String, Any?>()
|
||||
|
||||
fun set(path: String, value: Double?) {
|
||||
if (value == null) return
|
||||
ocm.config.set("old-tool-damage.damages.$path", value)
|
||||
}
|
||||
|
||||
try {
|
||||
set("TRIDENT", tridentMelee)
|
||||
set("TRIDENT_THROWN", tridentThrown)
|
||||
set("MACE", mace)
|
||||
toolDamageModule.reload()
|
||||
WeaponDamages.initialise(ocm)
|
||||
ModuleLoader.toggleModules()
|
||||
block()
|
||||
} finally {
|
||||
// restore
|
||||
snapshot.forEach { (k, v) -> ocm.config.set("old-tool-damage.damages.$k", v) }
|
||||
toolDamageModule.reload()
|
||||
WeaponDamages.initialise(ocm)
|
||||
ModuleLoader.toggleModules()
|
||||
}
|
||||
}
|
||||
|
||||
data class SpawnedPlayer(val fake: FakePlayer, val player: Player)
|
||||
|
||||
fun spawnFake(location: Location): SpawnedPlayer {
|
||||
lateinit var fake: FakePlayer
|
||||
lateinit var player: Player
|
||||
runSync {
|
||||
fake = FakePlayer(testPlugin)
|
||||
fake.spawn(location)
|
||||
player = checkNotNull(Bukkit.getPlayer(fake.uuid))
|
||||
player.gameMode = GameMode.SURVIVAL
|
||||
player.isInvulnerable = false
|
||||
player.inventory.clear()
|
||||
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
|
||||
val data = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
|
||||
data.setModesetForWorld(player.world.uid, "old")
|
||||
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, data)
|
||||
}
|
||||
return SpawnedPlayer(fake, player)
|
||||
}
|
||||
|
||||
fun cleanup(vararg players: SpawnedPlayer) {
|
||||
runSync {
|
||||
players.forEach { p ->
|
||||
p.fake.removePlayer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("trident melee uses configured base damage") {
|
||||
if (!CompatibilityCapabilities.isMaterialAvailable("TRIDENT")) return@test
|
||||
val tridentMat = Material.matchMaterial("TRIDENT") ?: return@test
|
||||
withWeaponConfig(tridentMelee = 12.0, tridentThrown = null, mace = null) {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val attacker = spawnFake(Location(world, 0.0, 100.0, 0.0))
|
||||
val victim = spawnFake(Location(world, 1.5, 100.0, 0.0))
|
||||
val damageCapture = AtomicReference<Double?>()
|
||||
val listener = object : Listener {
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
|
||||
fun onHit(event: EntityDamageByEntityEvent) {
|
||||
if (event.damager == attacker.player && event.entity == victim.player) {
|
||||
damageCapture.set(event.damage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runSync {
|
||||
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
|
||||
attacker.player.inventory.setItemInMainHand(ItemStack(tridentMat))
|
||||
Bukkit.getPluginManager().callEvent(
|
||||
EntityDamageByEntityEvent(attacker.player, victim.player, EntityDamageEvent.DamageCause.ENTITY_ATTACK, 8.0)
|
||||
)
|
||||
HandlerList.unregisterAll(listener)
|
||||
}
|
||||
|
||||
val dealt = damageCapture.get() ?: error("No damage recorded")
|
||||
dealt shouldBe (12.0 plusOrMinus 0.05)
|
||||
cleanup(attacker, victim)
|
||||
}
|
||||
}
|
||||
|
||||
test("thrown trident uses configured damage") {
|
||||
if (!CompatibilityCapabilities.isMaterialAvailable("TRIDENT") ||
|
||||
!CompatibilityCapabilities.isBukkitClassAvailable("org.bukkit.entity.Trident")) return@test
|
||||
val tridentMat = Material.matchMaterial("TRIDENT") ?: return@test
|
||||
withWeaponConfig(tridentMelee = null, tridentThrown = 15.0, mace = null) {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val victim = spawnFake(Location(world, 0.0, 100.0, 0.0))
|
||||
val tridentRef = AtomicReference<Trident>()
|
||||
runSync {
|
||||
tridentRef.set(
|
||||
world.spawn(world.spawnLocation, Trident::class.java).apply {
|
||||
this.item = ItemStack(tridentMat)
|
||||
}
|
||||
)
|
||||
}
|
||||
val damageCapture = AtomicReference<Double?>()
|
||||
val listener = object : Listener {
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
|
||||
fun onHit(event: EntityDamageByEntityEvent) {
|
||||
if (event.damager == tridentRef.get() && event.entity == victim.player) {
|
||||
damageCapture.set(event.damage)
|
||||
}
|
||||
}
|
||||
}
|
||||
runSync {
|
||||
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
|
||||
Bukkit.getPluginManager().callEvent(
|
||||
EntityDamageByEntityEvent(tridentRef.get(), victim.player, EntityDamageEvent.DamageCause.PROJECTILE, 8.0)
|
||||
)
|
||||
HandlerList.unregisterAll(listener)
|
||||
}
|
||||
|
||||
val dealt = damageCapture.get() ?: error("No damage recorded")
|
||||
dealt shouldBeExactly 15.0
|
||||
cleanup(victim)
|
||||
runSync { tridentRef.get()?.remove() }
|
||||
}
|
||||
}
|
||||
|
||||
test("mace melee uses configured base damage") {
|
||||
if (!CompatibilityCapabilities.isMaterialAvailable("MACE")) return@test
|
||||
val maceMat = Material.matchMaterial("MACE") ?: return@test
|
||||
withWeaponConfig(tridentMelee = null, tridentThrown = null, mace = 10.0) {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val attacker = spawnFake(Location(world, 0.0, 100.0, 0.0))
|
||||
val victim = spawnFake(Location(world, 1.5, 100.0, 0.0))
|
||||
val damageCapture = AtomicReference<Double?>()
|
||||
val listener = object : Listener {
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
|
||||
fun onHit(event: EntityDamageByEntityEvent) {
|
||||
if (event.damager == attacker.player && event.entity == victim.player) {
|
||||
damageCapture.set(event.damage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runSync {
|
||||
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
|
||||
attacker.player.inventory.setItemInMainHand(ItemStack(maceMat))
|
||||
Bukkit.getPluginManager().callEvent(
|
||||
EntityDamageByEntityEvent(attacker.player, victim.player, EntityDamageEvent.DamageCause.ENTITY_ATTACK, 6.0)
|
||||
)
|
||||
HandlerList.unregisterAll(listener)
|
||||
}
|
||||
|
||||
val dealt = damageCapture.get() ?: error("No damage recorded")
|
||||
dealt shouldBe (10.0 plusOrMinus 0.05)
|
||||
cleanup(attacker, victim)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import com.cryptomorin.xseries.XEnchantment
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.doubles.plusOrMinus
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.damage.DefenceUtils
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.event.entity.EntityDamageEvent
|
||||
import org.bukkit.inventory.ItemStack
|
||||
|
||||
class DefenceUtilsIntegrationTest :
|
||||
FunSpec({
|
||||
val enchantmentReductionMethod =
|
||||
DefenceUtils::class.java.getDeclaredMethod(
|
||||
"calculateArmourEnchantmentReductionFactor",
|
||||
Array<ItemStack>::class.java,
|
||||
EntityDamageEvent.DamageCause::class.java,
|
||||
Boolean::class.javaPrimitiveType
|
||||
).also { it.isAccessible = true }
|
||||
|
||||
fun protectionArmour(): Array<ItemStack> {
|
||||
val protection = checkNotNull(XEnchantment.PROTECTION.get())
|
||||
return arrayOf(
|
||||
ItemStack(Material.DIAMOND_BOOTS),
|
||||
ItemStack(Material.DIAMOND_LEGGINGS),
|
||||
ItemStack(Material.DIAMOND_CHESTPLATE),
|
||||
ItemStack(Material.DIAMOND_HELMET)
|
||||
).also { armour ->
|
||||
armour.forEach { it.addUnsafeEnchantment(protection, 4) }
|
||||
}
|
||||
}
|
||||
|
||||
fun enchantmentReduction(cause: EntityDamageEvent.DamageCause): Double = enchantmentReductionMethod.invoke(
|
||||
null,
|
||||
protectionArmour(),
|
||||
cause,
|
||||
false
|
||||
) as Double
|
||||
|
||||
test("protection applies to burn ticks like vanilla 1.8") {
|
||||
enchantmentReduction(EntityDamageEvent.DamageCause.FIRE_TICK) shouldBe (0.8 plusOrMinus 0.0001)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleDisableOffHand
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class DisableOffhandIntegrationTest : FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val module = ModuleLoader.getModules()
|
||||
.filterIsInstance<ModuleDisableOffHand>()
|
||||
.firstOrNull() ?: error("ModuleDisableOffHand not registered")
|
||||
|
||||
lateinit var player: Player
|
||||
lateinit var fakePlayer: FakePlayer
|
||||
|
||||
fun runSync(action: () -> Unit) {
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
|
||||
action()
|
||||
null
|
||||
}).get()
|
||||
}
|
||||
}
|
||||
|
||||
fun setModeset(player: Player, modeset: String) {
|
||||
val playerData = getPlayerData(player.uniqueId)
|
||||
playerData.setModesetForWorld(player.world.uid, modeset)
|
||||
setPlayerData(player.uniqueId, playerData)
|
||||
}
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
beforeSpec {
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getServer().getWorld("world"))
|
||||
fakePlayer = FakePlayer(testPlugin)
|
||||
fakePlayer.spawn(Location(world, 0.0, 100.0, 0.0))
|
||||
player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))
|
||||
}
|
||||
}
|
||||
|
||||
afterSpec {
|
||||
runSync {
|
||||
fakePlayer.removePlayer()
|
||||
}
|
||||
}
|
||||
|
||||
beforeTest {
|
||||
runSync {
|
||||
player.inventory.clear()
|
||||
player.inventory.setItemInOffHand(ItemStack(Material.SHIELD))
|
||||
setModeset(player, "new")
|
||||
}
|
||||
}
|
||||
|
||||
test("modeset-change handler ignores players without disable-offhand enabled") {
|
||||
runSync {
|
||||
module.isEnabled(player) shouldBe false
|
||||
val offhand = player.inventory.itemInOffHand.clone()
|
||||
|
||||
module.onModesetChange(player)
|
||||
|
||||
player.inventory.itemInOffHand.type shouldBe offhand.type
|
||||
player.inventory.itemInOffHand.amount shouldBe offhand.amount
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.inventory.ClickType
|
||||
import org.bukkit.event.inventory.InventoryAction
|
||||
import org.bukkit.event.inventory.InventoryClickEvent
|
||||
import org.bukkit.event.inventory.InventoryType
|
||||
import org.bukkit.inventory.Inventory
|
||||
import org.bukkit.inventory.InventoryView
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class DisableOffhandReflectionIntegrationTest :
|
||||
FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
fun <T> runSync(action: () -> T): T =
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable { action() }).get()
|
||||
}
|
||||
|
||||
lateinit var fake: FakePlayer
|
||||
lateinit var player: Player
|
||||
|
||||
beforeSpec {
|
||||
runSync {
|
||||
val world = Bukkit.getWorld("world") ?: error("world missing")
|
||||
fake = FakePlayer(testPlugin)
|
||||
fake.spawn(Location(world, 0.0, 100.0, 0.0))
|
||||
player = Bukkit.getPlayer(fake.uuid) ?: error("player missing")
|
||||
player.gameMode = GameMode.SURVIVAL
|
||||
player.isInvulnerable = false
|
||||
}
|
||||
}
|
||||
|
||||
afterSpec {
|
||||
runSync {
|
||||
fake.removePlayer()
|
||||
}
|
||||
}
|
||||
|
||||
beforeTest {
|
||||
runSync {
|
||||
player.closeInventory()
|
||||
}
|
||||
}
|
||||
|
||||
test("reflective InventoryClickEvent getView works") {
|
||||
val view = runSync { player.openInventory(player.inventory) } ?: error("inventory view missing")
|
||||
try {
|
||||
val event =
|
||||
runSync {
|
||||
InventoryClickEvent(
|
||||
view,
|
||||
InventoryType.SlotType.CONTAINER,
|
||||
0,
|
||||
ClickType.LEFT,
|
||||
InventoryAction.PICKUP_ALL,
|
||||
)
|
||||
}
|
||||
val method = Reflector.getMethod(event.javaClass, "getView") ?: error("getView missing")
|
||||
val reflectedView = Reflector.invokeMethod<InventoryView>(method, event)
|
||||
reflectedView shouldBe view
|
||||
} finally {
|
||||
runSync { player.closeInventory() }
|
||||
}
|
||||
}
|
||||
|
||||
test("reflective InventoryView access returns top and bottom inventories") {
|
||||
val view = runSync { player.openInventory(player.inventory) } ?: error("inventory view missing")
|
||||
try {
|
||||
val bottomMethod =
|
||||
Reflector.getMethod(view.javaClass, "getBottomInventory")
|
||||
?: error("getBottomInventory missing")
|
||||
val topMethod =
|
||||
Reflector.getMethod(view.javaClass, "getTopInventory")
|
||||
?: error("getTopInventory missing")
|
||||
|
||||
val bottom =
|
||||
Reflector.invokeMethod<Inventory>(bottomMethod, view)
|
||||
?: error("bottom inventory missing")
|
||||
val top =
|
||||
Reflector.invokeMethod<Inventory>(topMethod, view)
|
||||
?: error("top inventory missing")
|
||||
|
||||
bottom.type shouldBe InventoryType.PLAYER
|
||||
} finally {
|
||||
runSync { player.closeInventory() }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.longs.shouldBeGreaterThan
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleDisableEnderpearlCooldown
|
||||
import kotlinx.coroutines.delay
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.entity.EnderPearl
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.entity.ProjectileLaunchEvent
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class EnderpearlCooldownIntegrationTest : FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
val module = ModuleLoader.getModules().filterIsInstance<ModuleDisableEnderpearlCooldown>().firstOrNull()
|
||||
?: error("ModuleDisableEnderpearlCooldown not registered")
|
||||
|
||||
lateinit var player: Player
|
||||
lateinit var fakePlayer: FakePlayer
|
||||
|
||||
fun <T> runSync(action: () -> T): T {
|
||||
return if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
|
||||
action()
|
||||
}).get()
|
||||
}
|
||||
|
||||
fun setModeset(player: Player, modeset: String) {
|
||||
val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
|
||||
playerData.setModesetForWorld(player.world.uid, modeset)
|
||||
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)
|
||||
ModuleLoader.toggleModules()
|
||||
}
|
||||
|
||||
suspend fun withConfig(cooldownSeconds: Int, showMessage: Boolean, block: suspend () -> Unit) {
|
||||
val oldCooldown = ocm.config.getInt("disable-enderpearl-cooldown.cooldown")
|
||||
val oldShow = ocm.config.getBoolean("disable-enderpearl-cooldown.showMessage")
|
||||
try {
|
||||
runSync {
|
||||
ocm.config.set("disable-enderpearl-cooldown.cooldown", cooldownSeconds)
|
||||
ocm.config.set("disable-enderpearl-cooldown.showMessage", showMessage)
|
||||
module.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
}
|
||||
block()
|
||||
} finally {
|
||||
runSync {
|
||||
ocm.config.set("disable-enderpearl-cooldown.cooldown", oldCooldown)
|
||||
ocm.config.set("disable-enderpearl-cooldown.showMessage", oldShow)
|
||||
module.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun firePearlLaunchEvent(player: Player): ProjectileLaunchEvent {
|
||||
// Spawn a pearl entity directly; the module cancels this and launches a replacement.
|
||||
val pearl = runSync {
|
||||
val world = player.world
|
||||
val entity = world.spawn(player.eyeLocation, EnderPearl::class.java)
|
||||
entity.shooter = player
|
||||
entity
|
||||
}
|
||||
val event = ProjectileLaunchEvent(pearl)
|
||||
runSync { Bukkit.getPluginManager().callEvent(event) }
|
||||
return event
|
||||
}
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
beforeSpec {
|
||||
runSync {
|
||||
val world = Bukkit.getWorld("world") ?: error("world not loaded")
|
||||
val location = Location(world, 0.0, 120.0, 0.0, 0f, 0f)
|
||||
fakePlayer = FakePlayer(testPlugin)
|
||||
fakePlayer.spawn(location)
|
||||
player = Bukkit.getPlayer(fakePlayer.uuid) ?: error("Player not found")
|
||||
player.isOp = true
|
||||
setModeset(player, "old")
|
||||
}
|
||||
}
|
||||
|
||||
afterSpec {
|
||||
runSync { fakePlayer.removePlayer() }
|
||||
}
|
||||
|
||||
beforeTest {
|
||||
runSync {
|
||||
setModeset(player, "old")
|
||||
player.gameMode = GameMode.SURVIVAL
|
||||
player.inventory.clear()
|
||||
player.inventory.setItemInMainHand(ItemStack(Material.ENDER_PEARL, 16))
|
||||
}
|
||||
}
|
||||
|
||||
test("cooldown 0 allows repeated throws and consumes an enderpearl in survival") {
|
||||
withConfig(cooldownSeconds = 0, showMessage = false) {
|
||||
val before = runSync { player.inventory.itemInMainHand.amount }
|
||||
val e1 = firePearlLaunchEvent(player)
|
||||
e1.isCancelled shouldBe true
|
||||
val after1 = runSync { player.inventory.itemInMainHand.amount }
|
||||
(before - after1) shouldBe 1
|
||||
|
||||
val e2 = firePearlLaunchEvent(player)
|
||||
e2.isCancelled shouldBe true
|
||||
val after2 = runSync { player.inventory.itemInMainHand.amount }
|
||||
(after1 - after2) shouldBe 1
|
||||
}
|
||||
}
|
||||
|
||||
test("cooldown blocks a second throw within the window and exposes remaining cooldown") {
|
||||
withConfig(cooldownSeconds = 5, showMessage = false) {
|
||||
val e1 = firePearlLaunchEvent(player)
|
||||
e1.isCancelled shouldBe true
|
||||
|
||||
val remainingAfterFirst = runSync { module.getEnderpearlCooldown(player.uniqueId) }
|
||||
remainingAfterFirst shouldBeGreaterThan 0
|
||||
|
||||
val before2 = runSync { player.inventory.itemInMainHand.amount }
|
||||
val e2 = firePearlLaunchEvent(player)
|
||||
e2.isCancelled shouldBe true
|
||||
|
||||
// Second throw attempt should not consume another pearl.
|
||||
val after2 = runSync { player.inventory.itemInMainHand.amount }
|
||||
before2 shouldBe after2
|
||||
}
|
||||
}
|
||||
|
||||
test("creative mode does not consume enderpearls") {
|
||||
withConfig(cooldownSeconds = 0, showMessage = false) {
|
||||
runSync { player.gameMode = GameMode.CREATIVE }
|
||||
val before = runSync { player.inventory.itemInMainHand.amount }
|
||||
val e1 = firePearlLaunchEvent(player)
|
||||
e1.isCancelled shouldBe true
|
||||
val after = runSync { player.inventory.itemInMainHand.amount }
|
||||
before shouldBe after
|
||||
}
|
||||
}
|
||||
|
||||
test("no enderpearl item in either hand does not throw or consume") {
|
||||
withConfig(cooldownSeconds = 0, showMessage = false) {
|
||||
runSync { player.inventory.clear() }
|
||||
val e1 = firePearlLaunchEvent(player)
|
||||
e1.isCancelled shouldBe true
|
||||
runSync { player.inventory.itemInMainHand.type shouldBe Material.AIR }
|
||||
}
|
||||
}
|
||||
|
||||
test("cooldown expires after real time and throw becomes allowed again") {
|
||||
withConfig(cooldownSeconds = 1, showMessage = false) {
|
||||
val e1 = firePearlLaunchEvent(player)
|
||||
e1.isCancelled shouldBe true
|
||||
|
||||
// Wait slightly over a second to ensure wall-clock cooldown is over.
|
||||
delay(1200)
|
||||
|
||||
val before2 = runSync { player.inventory.itemInMainHand.amount }
|
||||
val e2 = firePearlLaunchEvent(player)
|
||||
e2.isCancelled shouldBe true
|
||||
val after2 = runSync { player.inventory.itemInMainHand.amount }
|
||||
(before2 - after2) shouldBe 1
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,876 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import com.mojang.authlib.GameProfile
|
||||
import io.netty.channel.ChannelInboundHandlerAdapter
|
||||
import io.netty.channel.ChannelOutboundHandlerAdapter
|
||||
import io.netty.channel.embedded.EmbeddedChannel
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.entity.Entity
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.player.AsyncPlayerPreLoginEvent
|
||||
import org.bukkit.event.player.PlayerJoinEvent
|
||||
import org.bukkit.event.player.PlayerPreLoginEvent
|
||||
import org.bukkit.event.player.PlayerQuitEvent
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import xyz.jpenilla.reflectionremapper.ReflectionRemapper
|
||||
import java.lang.reflect.Method
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.SocketAddress
|
||||
import java.net.UnknownHostException
|
||||
import java.util.*
|
||||
|
||||
class FakePlayer(private val plugin: JavaPlugin) {
|
||||
val uuid: UUID = UUID.randomUUID()
|
||||
private val name: String = uuid.toString().substring(0, 16)
|
||||
private lateinit var serverPlayer: Any // NMS ServerPlayer instance
|
||||
private var bukkitPlayer: Player? = null
|
||||
private var networkConnection: Any? = null
|
||||
private var usedPlaceNewPlayer: Boolean = false
|
||||
private var tickTaskId: Int? = null
|
||||
private val isLegacy9 = !Reflector.versionIsNewerOrEqualTo(1, 10, 0) // 1.9.x and below
|
||||
private val isLegacy12 = !Reflector.versionIsNewerOrEqualTo(1, 13, 0) && Reflector.versionIsNewerOrEqualTo(1, 10, 0)
|
||||
private val legacyImpl9: LegacyFakePlayer9? = if (isLegacy9) LegacyFakePlayer9(plugin, uuid, name) else null
|
||||
private val legacyImpl12: LegacyFakePlayer12? = if (isLegacy12) LegacyFakePlayer12(plugin, uuid, name) else null
|
||||
private val reflectionRemapper: ReflectionRemapper = try {
|
||||
ReflectionRemapper.forReobfMappingsInPaperJar()
|
||||
} catch (e: Throwable) {
|
||||
plugin.logger.warning("Reflection mappings not found; using no-op remapper for legacy server.")
|
||||
ReflectionRemapper.noop()
|
||||
}
|
||||
|
||||
// Helper function to load NMS classes using the appropriate class loader and remap names
|
||||
fun getNMSClass(name: String): Class<*> {
|
||||
// Remap the class name
|
||||
val remappedName = reflectionRemapper.remapClassName(name)
|
||||
// Get the NMS MinecraftServer from the Bukkit server
|
||||
val server = Bukkit.getServer()
|
||||
val craftServerClass = server.javaClass
|
||||
val getServerMethod = Reflector.getMethod(craftServerClass, "getServer")
|
||||
?: throw NoSuchMethodException("Cannot find getServer method in ${craftServerClass.name}")
|
||||
val minecraftServer = Reflector.invokeMethod<Any>(getServerMethod, server)
|
||||
|
||||
return Class.forName(remappedName, true, minecraftServer.javaClass.classLoader)
|
||||
}
|
||||
|
||||
fun spawn(location: Location) {
|
||||
if (isLegacy9) {
|
||||
legacyImpl9!!.spawn(location)
|
||||
serverPlayer = legacyImpl9.entityPlayer ?: throw IllegalStateException("Legacy9 entity player not created.")
|
||||
bukkitPlayer = legacyImpl9.bukkitPlayer
|
||||
return
|
||||
}
|
||||
if (isLegacy12) {
|
||||
legacyImpl12!!.spawn(location)
|
||||
serverPlayer = legacyImpl12.entityPlayer ?: throw IllegalStateException("Legacy12 entity player not created.")
|
||||
bukkitPlayer = legacyImpl12.bukkitPlayer
|
||||
return
|
||||
}
|
||||
plugin.logger.info("Spawn: Starting")
|
||||
|
||||
// Get the NMS WorldServer (ServerLevel) from the Bukkit world
|
||||
val world = location.world ?: throw IllegalArgumentException("Location has no world!")
|
||||
val craftWorldClass = world.javaClass
|
||||
val getHandleMethod = Reflector.getMethod(craftWorldClass, "getHandle")
|
||||
?: throw NoSuchMethodException("Cannot find getHandle method in ${craftWorldClass.name}")
|
||||
val worldServer = Reflector.invokeMethod<Any>(getHandleMethod, world)
|
||||
|
||||
val minecraftServer = getMinecraftServer()
|
||||
|
||||
// Create a GameProfile for the fake player
|
||||
val gameProfile = GameProfile(uuid, name)
|
||||
|
||||
// Get the ServerPlayer class and its constructor
|
||||
val minecraftServerClass = getNMSClass("net.minecraft.server.MinecraftServer")
|
||||
val serverPlayerClass = getNMSClass("net.minecraft.server.level.ServerPlayer")
|
||||
|
||||
// Create a new instance of ServerPlayer (constructor signature varies by version)
|
||||
this.serverPlayer = createServerPlayer(
|
||||
serverPlayerClass,
|
||||
minecraftServerClass,
|
||||
minecraftServer,
|
||||
worldServer,
|
||||
gameProfile
|
||||
)
|
||||
plugin.logger.info("Spawn: created serverPlayer")
|
||||
|
||||
// Set up the connection for the ServerPlayer
|
||||
setupPlayerConnection(minecraftServer, worldServer)
|
||||
|
||||
// Set the GameMode to SURVIVAL
|
||||
setPlayerGameMode("SURVIVAL", minecraftServer)
|
||||
|
||||
setPlayerPosition(location)
|
||||
setPlayerRotation(0f, 0f)
|
||||
|
||||
// Fire AsyncPlayerPreLoginEvent
|
||||
fireAsyncPlayerPreLoginEvent()
|
||||
|
||||
// Add the player to the server's player list
|
||||
usedPlaceNewPlayer = addToPlayerList(minecraftServer)
|
||||
|
||||
// Retrieve the Bukkit Player instance
|
||||
bukkitPlayer = Bukkit.getPlayer(uuid)
|
||||
?: throw RuntimeException("Bukkit player with UUID $uuid not found!")
|
||||
|
||||
// Fire PlayerJoinEvent
|
||||
firePlayerJoinEvent()
|
||||
|
||||
// Notify other players and spawn the fake player
|
||||
if (!usedPlaceNewPlayer) {
|
||||
notifyPlayersOfJoin()
|
||||
spawnPlayerInWorld(worldServer, minecraftServer)
|
||||
} else if (bukkitPlayer?.world?.players?.contains(bukkitPlayer) != true) {
|
||||
spawnPlayerInWorld(worldServer, minecraftServer)
|
||||
}
|
||||
|
||||
scheduleServerPlayerTick()
|
||||
|
||||
plugin.logger.info("Spawn: completed successfully")
|
||||
}
|
||||
|
||||
fun teleport(location: Location): Boolean {
|
||||
if (isLegacy9) {
|
||||
val teleported = legacyImpl9!!.teleport(location)
|
||||
if (teleported) {
|
||||
serverPlayer = legacyImpl9.entityPlayer ?: serverPlayer
|
||||
bukkitPlayer = legacyImpl9.bukkitPlayer ?: Bukkit.getPlayer(uuid)
|
||||
}
|
||||
return teleported
|
||||
}
|
||||
|
||||
val teleported = bukkitPlayer?.teleport(location) ?: false
|
||||
if (teleported) {
|
||||
bukkitPlayer = Bukkit.getPlayer(uuid) ?: bukkitPlayer
|
||||
}
|
||||
return teleported
|
||||
}
|
||||
|
||||
fun requireBukkitPlayer(): Player = Bukkit.getPlayer(uuid) ?: bukkitPlayer
|
||||
?: error("Bukkit player with UUID $uuid not found!")
|
||||
|
||||
private fun setupPlayerConnection(minecraftServer: Any, worldServer: Any) {
|
||||
// Access ServerGamePacketListenerImpl class
|
||||
val serverGamePacketListenerImplClass = getNMSClass("net.minecraft.server.network.ServerGamePacketListenerImpl")
|
||||
|
||||
// Create a new Connection object
|
||||
val connectionClass = getNMSClass("net.minecraft.network.Connection")
|
||||
val packetFlowClass = getNMSClass("net.minecraft.network.protocol.PacketFlow")
|
||||
val serverboundFieldName = reflectionRemapper.remapFieldName(packetFlowClass, "SERVERBOUND")
|
||||
val packetFlow = runCatching {
|
||||
Reflector.getEnumConstant(packetFlowClass, serverboundFieldName, "SERVERBOUND")
|
||||
}.getOrElse {
|
||||
val clientboundFieldName = reflectionRemapper.remapFieldName(packetFlowClass, "CLIENTBOUND")
|
||||
Reflector.getEnumConstant(packetFlowClass, clientboundFieldName, "CLIENTBOUND")
|
||||
}
|
||||
val connectionConstructor = connectionClass.getConstructor(packetFlowClass)
|
||||
val connection = connectionConstructor.newInstance(packetFlow)
|
||||
networkConnection = connection
|
||||
|
||||
// Create a custom EmbeddedChannel with an overridden remoteAddress()
|
||||
val remoteAddress = InetSocketAddress("127.0.0.1", 9999)
|
||||
val embeddedChannel = EmbeddedChannel(ChannelInboundHandlerAdapter())
|
||||
val pipeline = embeddedChannel.pipeline()
|
||||
if (pipeline.get("decoder") == null) {
|
||||
pipeline.addLast("decoder", ChannelInboundHandlerAdapter())
|
||||
}
|
||||
if (pipeline.get("encoder") == null) {
|
||||
pipeline.addLast("encoder", ChannelOutboundHandlerAdapter())
|
||||
}
|
||||
|
||||
// Set the 'channel' field of 'connection' to the custom EmbeddedChannel
|
||||
val channelFieldName = reflectionRemapper.remapFieldName(connectionClass, "channel")
|
||||
val channelField = Reflector.getField(connectionClass, channelFieldName)
|
||||
channelField.isAccessible = true
|
||||
channelField.set(connection, embeddedChannel)
|
||||
|
||||
// Set address field of connection
|
||||
val addressFieldName = reflectionRemapper.remapFieldName(connectionClass, "address")
|
||||
val addressField = Reflector.getField(connectionClass, addressFieldName)
|
||||
addressField.set(connection, remoteAddress)
|
||||
|
||||
// Create a new ServerGamePacketListenerImpl instance (constructor signature varies by version)
|
||||
val serverPlayerClass = serverPlayer.javaClass
|
||||
val minecraftServerClass = getNMSClass("net.minecraft.server.MinecraftServer")
|
||||
val listenerInstance = createServerGamePacketListener(
|
||||
serverGamePacketListenerImplClass,
|
||||
minecraftServerClass,
|
||||
connectionClass,
|
||||
serverPlayerClass,
|
||||
minecraftServer,
|
||||
connection,
|
||||
serverPlayer
|
||||
)
|
||||
|
||||
// Set the listenerInstance to the player's 'connection' field
|
||||
val connectionFieldName = reflectionRemapper.remapFieldName(serverPlayerClass, "connection")
|
||||
val connectionField = Reflector.getField(serverPlayerClass, connectionFieldName)
|
||||
Reflector.setFieldValue(connectionField, serverPlayer, listenerInstance)
|
||||
|
||||
val setListenerName = reflectionRemapper.remapMethodName(
|
||||
connectionClass,
|
||||
"setListener",
|
||||
listenerInstance.javaClass
|
||||
)
|
||||
val setListenerMethod = Reflector.getMethodAssignable(
|
||||
connectionClass,
|
||||
setListenerName,
|
||||
listenerInstance.javaClass
|
||||
) ?: Reflector.getMethodAssignable(connectionClass, "setListener", listenerInstance.javaClass)
|
||||
if (setListenerMethod != null) {
|
||||
Reflector.invokeMethod<Any>(setListenerMethod, connection, listenerInstance)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createServerGamePacketListener(
|
||||
listenerClass: Class<*>,
|
||||
minecraftServerClass: Class<*>,
|
||||
connectionClass: Class<*>,
|
||||
serverPlayerClass: Class<*>,
|
||||
minecraftServer: Any,
|
||||
connection: Any,
|
||||
serverPlayer: Any
|
||||
): Any {
|
||||
val constructors = listenerClass.constructors.sortedBy { it.parameterCount }
|
||||
for (ctor in constructors) {
|
||||
val params = ctor.parameterTypes
|
||||
if (params.size < 3) continue
|
||||
if (!params[0].isAssignableFrom(minecraftServerClass)) continue
|
||||
if (!params[1].isAssignableFrom(connectionClass)) continue
|
||||
if (!params[2].isAssignableFrom(serverPlayerClass)) continue
|
||||
|
||||
val args = ArrayList<Any?>()
|
||||
args.add(minecraftServer)
|
||||
args.add(connection)
|
||||
args.add(serverPlayer)
|
||||
|
||||
var supported = true
|
||||
for (i in 3 until params.size) {
|
||||
val param = params[i]
|
||||
when (param.simpleName) {
|
||||
"CommonListenerCookie" -> args.add(createCommonListenerCookie(param, serverPlayer))
|
||||
else -> {
|
||||
supported = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!supported) continue
|
||||
return ctor.newInstance(*args.toTypedArray())
|
||||
}
|
||||
|
||||
throw NoSuchMethodException("No compatible ServerGamePacketListenerImpl constructor found for ${listenerClass.name}")
|
||||
}
|
||||
|
||||
private fun createCommonListenerCookie(cookieClass: Class<*>, serverPlayer: Any): Any {
|
||||
val getProfileName = reflectionRemapper.remapMethodName(serverPlayer.javaClass, "getGameProfile")
|
||||
val getProfileMethod = Reflector.getMethod(serverPlayer.javaClass, getProfileName)
|
||||
?: Reflector.getMethod(serverPlayer.javaClass, "getGameProfile")
|
||||
?: throw NoSuchMethodException("getGameProfile not found in ${serverPlayer.javaClass.name}")
|
||||
val gameProfile = Reflector.invokeMethod<GameProfile>(getProfileMethod, serverPlayer)
|
||||
|
||||
val remappedName = reflectionRemapper.remapMethodName(
|
||||
cookieClass,
|
||||
"createInitial",
|
||||
GameProfile::class.java,
|
||||
Boolean::class.javaPrimitiveType
|
||||
)
|
||||
val method = Reflector.getMethod(cookieClass, remappedName, "GameProfile", "boolean")
|
||||
?: Reflector.getMethod(cookieClass, "createInitial", "GameProfile", "boolean")
|
||||
?: throw NoSuchMethodException("createInitial not found in ${cookieClass.name}")
|
||||
return Reflector.invokeMethod(method, null, gameProfile, false)
|
||||
}
|
||||
|
||||
private fun createServerPlayer(
|
||||
serverPlayerClass: Class<*>,
|
||||
minecraftServerClass: Class<*>,
|
||||
minecraftServer: Any,
|
||||
worldServer: Any,
|
||||
gameProfile: GameProfile
|
||||
): Any {
|
||||
val constructors = serverPlayerClass.constructors.sortedBy { it.parameterCount }
|
||||
for (ctor in constructors) {
|
||||
val params = ctor.parameterTypes
|
||||
if (params.size < 3) continue
|
||||
if (!params[0].isAssignableFrom(minecraftServerClass)) continue
|
||||
if (!params[1].isAssignableFrom(worldServer.javaClass)) continue
|
||||
if (params[2] != GameProfile::class.java) continue
|
||||
|
||||
val args = ArrayList<Any?>()
|
||||
args.add(minecraftServer)
|
||||
args.add(worldServer)
|
||||
args.add(gameProfile)
|
||||
|
||||
var supported = true
|
||||
for (i in 3 until params.size) {
|
||||
val param = params[i]
|
||||
when (param.simpleName) {
|
||||
"ProfilePublicKey" -> args.add(null)
|
||||
"ClientInformation" -> args.add(createDefaultClientInformation(param))
|
||||
else -> {
|
||||
supported = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!supported) continue
|
||||
return ctor.newInstance(*args.toTypedArray())
|
||||
}
|
||||
|
||||
throw NoSuchMethodException("No compatible ServerPlayer constructor found for ${serverPlayerClass.name}")
|
||||
}
|
||||
|
||||
private fun createDefaultClientInformation(clientInfoClass: Class<*>): Any {
|
||||
val remappedName = reflectionRemapper.remapMethodName(clientInfoClass, "createDefault")
|
||||
val method = Reflector.getMethod(clientInfoClass, remappedName)
|
||||
?: Reflector.getMethod(clientInfoClass, "createDefault")
|
||||
?: throw NoSuchMethodException("createDefault not found in ${clientInfoClass.name}")
|
||||
return Reflector.invokeMethod(method, null)
|
||||
}
|
||||
|
||||
private fun setPlayerGameMode(gameModeName: String, minecraftServer: Any) {
|
||||
val gameModeClass = getNMSClass("net.minecraft.world.level.GameType")
|
||||
val gameModeFieldName = reflectionRemapper.remapFieldName(gameModeClass, gameModeName)
|
||||
val gameModeField = Reflector.getField(gameModeClass, gameModeFieldName)
|
||||
val gameMode = gameModeField.get(null)
|
||||
val setGameModeMethodName = reflectionRemapper.remapMethodName(
|
||||
serverPlayer.javaClass,
|
||||
"setGameMode",
|
||||
gameModeClass
|
||||
)
|
||||
val setGameModeMethod = serverPlayer.javaClass.getMethod(setGameModeMethodName, gameModeClass)
|
||||
setGameModeMethod.invoke(serverPlayer, gameMode)
|
||||
}
|
||||
|
||||
private fun setPlayerPosition(location: Location) {
|
||||
val entityClass = getNMSClass("net.minecraft.world.entity.Entity")
|
||||
val setPosMethodName = reflectionRemapper.remapMethodName(
|
||||
entityClass,
|
||||
"setPos",
|
||||
Double::class.javaPrimitiveType,
|
||||
Double::class.javaPrimitiveType,
|
||||
Double::class.javaPrimitiveType
|
||||
)
|
||||
val setPosMethod = checkNotNull(
|
||||
Reflector.getMethod(
|
||||
entityClass,
|
||||
setPosMethodName,
|
||||
"double",
|
||||
"double",
|
||||
"double"
|
||||
)
|
||||
)
|
||||
setPosMethod.invoke(serverPlayer, location.x, location.y, location.z)
|
||||
}
|
||||
|
||||
private fun setPlayerRotation(xRot: Float, yRot: Float) {
|
||||
val entityClass = getNMSClass("net.minecraft.world.entity.Entity")
|
||||
val xRotFieldName = reflectionRemapper.remapFieldName(entityClass, "xRot")
|
||||
val xRotField = Reflector.getField(entityClass, xRotFieldName)
|
||||
xRotField.setFloat(serverPlayer, xRot)
|
||||
|
||||
val yRotFieldName = reflectionRemapper.remapFieldName(entityClass, "yRot")
|
||||
val yRotField = Reflector.getField(entityClass, yRotFieldName)
|
||||
yRotField.setFloat(serverPlayer, yRot)
|
||||
}
|
||||
|
||||
private fun fireAsyncPlayerPreLoginEvent() {
|
||||
try {
|
||||
val ipAddress = InetAddress.getByName("127.0.0.1")
|
||||
@Suppress("DEPRECATION") // Legacy constructor kept for older server compatibility in tests.
|
||||
val asyncPreLoginEvent = AsyncPlayerPreLoginEvent(name, ipAddress, uuid)
|
||||
Thread { Bukkit.getPluginManager().callEvent(asyncPreLoginEvent) }.start()
|
||||
} catch (e: UnknownHostException) {
|
||||
plugin.logger.severe("Spawn: UnknownHostException - ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun addToPlayerList(minecraftServer: Any): Boolean {
|
||||
val playerList = getPlayerList(minecraftServer)
|
||||
|
||||
// Add the player to the PlayerList
|
||||
val playerListClass = getNMSClass("net.minecraft.server.players.PlayerList")
|
||||
val placeMethodName = reflectionRemapper.remapMethodName(playerListClass, "placeNewPlayer")
|
||||
val connection = checkNotNull(networkConnection) { "Connection not initialised" }
|
||||
val placeMethodWithCookie = Reflector.getMethodAssignable(
|
||||
playerListClass,
|
||||
placeMethodName,
|
||||
connection.javaClass,
|
||||
serverPlayer.javaClass,
|
||||
null
|
||||
) ?: Reflector.getMethodAssignable(
|
||||
playerListClass,
|
||||
"placeNewPlayer",
|
||||
connection.javaClass,
|
||||
serverPlayer.javaClass,
|
||||
null
|
||||
)
|
||||
|
||||
if (placeMethodWithCookie != null && placeMethodWithCookie.parameterTypes.size == 3) {
|
||||
val cookieClass = placeMethodWithCookie.parameterTypes[2]
|
||||
val cookie = createCommonListenerCookie(cookieClass, serverPlayer)
|
||||
placeMethodWithCookie.invoke(playerList, connection, serverPlayer, cookie)
|
||||
return true
|
||||
}
|
||||
|
||||
val placeMethod = Reflector.getMethodAssignable(
|
||||
playerListClass,
|
||||
placeMethodName,
|
||||
connection.javaClass,
|
||||
serverPlayer.javaClass
|
||||
) ?: Reflector.getMethodAssignable(
|
||||
playerListClass,
|
||||
"placeNewPlayer",
|
||||
connection.javaClass,
|
||||
serverPlayer.javaClass
|
||||
)
|
||||
|
||||
if (placeMethod != null) {
|
||||
placeMethod.invoke(playerList, connection, serverPlayer)
|
||||
return true
|
||||
}
|
||||
|
||||
val loadMethodName = reflectionRemapper.remapMethodName(playerListClass, "load", serverPlayer.javaClass)
|
||||
val loadMethod = playerListClass.methods.firstOrNull { method ->
|
||||
method.name == loadMethodName &&
|
||||
method.parameterCount == 1 &&
|
||||
method.parameterTypes[0].isAssignableFrom(serverPlayer.javaClass)
|
||||
}
|
||||
|
||||
if (loadMethod != null) {
|
||||
Reflector.invokeMethod<Any>(loadMethod, playerList, serverPlayer)
|
||||
|
||||
val playersFieldName = reflectionRemapper.remapFieldName(playerListClass, "players")
|
||||
val playersField = playerListClass.getDeclaredField(playersFieldName)
|
||||
playersField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST") // Reflection into NMS collection; types vary by version.
|
||||
val players = playersField.get(playerList) as MutableList<Any>
|
||||
players.add(serverPlayer)
|
||||
|
||||
// Add player to the UUID map
|
||||
val playersByUUIDField = Reflector.getMapFieldWithTypes(
|
||||
playerListClass, UUID::class.java, serverPlayer.javaClass
|
||||
)
|
||||
@Suppress("UNCHECKED_CAST") // Reflection into NMS map; types vary by version.
|
||||
val playerByUUID = Reflector.getFieldValue(playersByUUIDField, playerList) as MutableMap<UUID, Any>
|
||||
playerByUUID[uuid] = serverPlayer
|
||||
return false
|
||||
}
|
||||
|
||||
throw NoSuchMethodException("No compatible PlayerList add method found for ${playerListClass.name}")
|
||||
}
|
||||
|
||||
private fun firePlayerJoinEvent() {
|
||||
val joinMessage = "$name joined the game"
|
||||
val playerJoinEvent = PlayerJoinEvent(bukkitPlayer!!, joinMessage)
|
||||
Bukkit.getPluginManager().callEvent(playerJoinEvent)
|
||||
}
|
||||
|
||||
private fun notifyPlayersOfJoin() {
|
||||
if (Bukkit.getOnlinePlayers().isEmpty()) return
|
||||
|
||||
val packet = runCatching { createLegacyPlayerInfoPacket() }.getOrNull()
|
||||
?: runCatching { createPlayerInfoUpdatePacket() }.getOrNull()
|
||||
?: return
|
||||
sendPacket(packet)
|
||||
}
|
||||
|
||||
private fun spawnPlayerInWorld(worldServer: Any, minecraftServer: Any) {
|
||||
// Add the player to the world
|
||||
val worldServerClass = worldServer.javaClass
|
||||
val addNewPlayerMethodName = reflectionRemapper.remapMethodName(
|
||||
worldServerClass,
|
||||
"addNewPlayer",
|
||||
serverPlayer.javaClass
|
||||
)
|
||||
val addNewPlayerMethod = Reflector.getMethodAssignable(
|
||||
worldServerClass,
|
||||
addNewPlayerMethodName,
|
||||
serverPlayer.javaClass
|
||||
)
|
||||
?: Reflector.getMethodAssignable(worldServerClass, "addNewPlayer", serverPlayer.javaClass)
|
||||
?: Reflector.getMethodAssignable(worldServerClass, "addPlayer", serverPlayer.javaClass)
|
||||
?: Reflector.getMethodAssignable(worldServerClass, "addFreshEntity", serverPlayer.javaClass)
|
||||
?: Reflector.getMethodAssignable(worldServerClass, "addEntity", serverPlayer.javaClass)
|
||||
if (addNewPlayerMethod != null) {
|
||||
addNewPlayerMethod.invoke(worldServer, serverPlayer)
|
||||
} else {
|
||||
plugin.logger.warning("Spawn: Could not find a world add method for ${worldServerClass.name}")
|
||||
}
|
||||
|
||||
// Send world info to the player
|
||||
val minecraftServerClass = getNMSClass("net.minecraft.server.MinecraftServer")
|
||||
runCatching {
|
||||
val getStatusMethodName = reflectionRemapper.remapMethodName(minecraftServerClass, "getStatus")
|
||||
val getStatusMethod = Reflector.getMethod(minecraftServerClass, getStatusMethodName)
|
||||
val status = Reflector.invokeMethod<Any>(getStatusMethod!!, minecraftServer)
|
||||
|
||||
val sendServerStatusMethodName = reflectionRemapper.remapMethodName(
|
||||
serverPlayer.javaClass,
|
||||
"sendServerStatus",
|
||||
status.javaClass
|
||||
)
|
||||
val sendServerStatusMethod = Reflector.getMethod(
|
||||
serverPlayer.javaClass,
|
||||
sendServerStatusMethodName,
|
||||
status.javaClass.simpleName
|
||||
) ?: Reflector.getMethod(serverPlayer.javaClass, "sendServerStatus", status.javaClass.simpleName)
|
||||
if (sendServerStatusMethod != null) {
|
||||
sendServerStatusMethod.invoke(serverPlayer, status)
|
||||
}
|
||||
}
|
||||
|
||||
// Send ClientboundAddPlayerPacket to all players
|
||||
runCatching {
|
||||
val clientboundAddPlayerPacketClass =
|
||||
getNMSClass("net.minecraft.network.protocol.game.ClientboundAddPlayerPacket")
|
||||
val playerClassName = getNMSClass("net.minecraft.world.entity.player.Player")
|
||||
// ServerPlayer is subclass of Player
|
||||
val clientboundAddPlayerPacketConstructor = clientboundAddPlayerPacketClass.getConstructor(playerClassName)
|
||||
|
||||
val packet = clientboundAddPlayerPacketConstructor.newInstance(serverPlayer)
|
||||
sendPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createLegacyPlayerInfoPacket(): Any {
|
||||
val clientboundPlayerInfoPacketClass =
|
||||
getNMSClass("net.minecraft.network.protocol.game.ClientboundPlayerInfoPacket")
|
||||
val actionClass = getNMSClass("net.minecraft.network.protocol.game.ClientboundPlayerInfoPacket\$Action")
|
||||
val addPlayerFieldName = reflectionRemapper.remapFieldName(actionClass, "ADD_PLAYER")
|
||||
val addPlayerAction = actionClass.getDeclaredField(addPlayerFieldName).get(null)
|
||||
|
||||
val clientboundPlayerInfoPacketConstructor = clientboundPlayerInfoPacketClass.getConstructor(
|
||||
actionClass,
|
||||
Collection::class.java
|
||||
)
|
||||
|
||||
return clientboundPlayerInfoPacketConstructor.newInstance(
|
||||
addPlayerAction,
|
||||
listOf(serverPlayer)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createPlayerInfoUpdatePacket(): Any {
|
||||
val packetClass = getNMSClass("net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket")
|
||||
val actionClass = getNMSClass("net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket\$Action")
|
||||
val addPlayerFieldName = reflectionRemapper.remapFieldName(actionClass, "ADD_PLAYER")
|
||||
val addPlayerAction = actionClass.getDeclaredField(addPlayerFieldName).get(null)
|
||||
|
||||
val createInitMethodName = reflectionRemapper.remapMethodName(
|
||||
packetClass,
|
||||
"createPlayerInitializing",
|
||||
serverPlayer.javaClass
|
||||
)
|
||||
val createInitMethod = Reflector.getMethodAssignable(
|
||||
packetClass,
|
||||
createInitMethodName,
|
||||
serverPlayer.javaClass
|
||||
) ?: Reflector.getMethodAssignable(packetClass, "createPlayerInitializing", serverPlayer.javaClass)
|
||||
if (createInitMethod != null) {
|
||||
return Reflector.invokeMethod(createInitMethod, null, serverPlayer)
|
||||
}
|
||||
|
||||
val constructor = packetClass.getConstructor(EnumSet::class.java, Collection::class.java)
|
||||
val enumSetNoneOf = EnumSet::class.java.getMethod("noneOf", Class::class.java)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val actions = enumSetNoneOf.invoke(null, actionClass) as MutableSet<Any>
|
||||
actions.add(addPlayerAction)
|
||||
return constructor.newInstance(actions, listOf(serverPlayer))
|
||||
}
|
||||
|
||||
private fun sendPacket(packet: Any) {
|
||||
// Get the Packet class
|
||||
val packetClass = getNMSClass("net.minecraft.network.protocol.Packet")
|
||||
|
||||
// Send packet to all online players
|
||||
Bukkit.getOnlinePlayers().forEach { player ->
|
||||
val craftPlayerClass = player.javaClass
|
||||
val getHandleMethodName = reflectionRemapper.remapMethodName(craftPlayerClass, "getHandle")
|
||||
val getHandleMethod = craftPlayerClass.getMethod(getHandleMethodName)
|
||||
val entityPlayer = getHandleMethod.invoke(player)
|
||||
|
||||
val connection = getConnection(entityPlayer)
|
||||
|
||||
val sendMethodName = reflectionRemapper.remapMethodName(connection.javaClass, "send", packetClass)
|
||||
val sendMethod = connection.javaClass.getMethod(sendMethodName, packetClass)
|
||||
sendMethod.invoke(connection, packet)
|
||||
}
|
||||
}
|
||||
|
||||
fun getConnection(serverPlayer: Any): Any {
|
||||
if (isLegacy9) return legacyImpl9!!.getConnection(serverPlayer)
|
||||
if (isLegacy12) return legacyImpl12!!.getConnection(serverPlayer)
|
||||
val entityPlayerClass = serverPlayer.javaClass
|
||||
val connectionFieldName = reflectionRemapper.remapFieldName(entityPlayerClass, "connection")
|
||||
val connectionField = Reflector.getField(entityPlayerClass, connectionFieldName)
|
||||
if (connectionField != null) return connectionField.get(serverPlayer)
|
||||
val legacyField = Reflector.getField(entityPlayerClass, "playerConnection")
|
||||
return legacyField?.get(serverPlayer) ?: error("No connection field on ${entityPlayerClass.name}")
|
||||
}
|
||||
|
||||
fun removePlayer() {
|
||||
tickTaskId?.let { Bukkit.getScheduler().cancelTask(it) }
|
||||
if (isLegacy9) {
|
||||
legacyImpl9!!.removePlayer()
|
||||
return
|
||||
}
|
||||
if (isLegacy12) {
|
||||
legacyImpl12!!.removePlayer()
|
||||
return
|
||||
}
|
||||
// Fire PlayerQuitEvent
|
||||
val quitMessage = "§e$name left the game"
|
||||
val playerQuitEvent = PlayerQuitEvent(bukkitPlayer!!, quitMessage)
|
||||
Bukkit.getPluginManager().callEvent(playerQuitEvent)
|
||||
|
||||
// Disconnect the player
|
||||
bukkitPlayer!!.kickPlayer(quitMessage)
|
||||
|
||||
// Remove the player from the world
|
||||
/*
|
||||
val removeMethodName = reflectionRemapper.remapMethodName(
|
||||
serverPlayer.javaClass,
|
||||
"remove"
|
||||
)
|
||||
val removeMethod = serverPlayer.javaClass.getMethod(removeMethodName)
|
||||
removeMethod.invoke(serverPlayer)
|
||||
*/
|
||||
|
||||
// Remove from playerList if still present and not already removed
|
||||
runCatching {
|
||||
val playerList = getPlayerList(getMinecraftServer())
|
||||
if (!isEntityRemoved(serverPlayer) && isPlayerListed(playerList)) {
|
||||
val removePlayerMethodName = reflectionRemapper.remapMethodName(
|
||||
playerList.javaClass,
|
||||
"remove",
|
||||
serverPlayer.javaClass
|
||||
)
|
||||
val removePlayerMethod = playerList.javaClass.getMethod(removePlayerMethodName, serverPlayer.javaClass)
|
||||
removePlayerMethod.invoke(playerList, serverPlayer)
|
||||
}
|
||||
}
|
||||
|
||||
// Close the connection properly
|
||||
val connection = getConnection(serverPlayer)
|
||||
val disconnectMethodName = reflectionRemapper.remapMethodName(
|
||||
connection.javaClass,
|
||||
"disconnect",
|
||||
getNMSClass("net.minecraft.network.chat.Component")
|
||||
)
|
||||
val disconnectMethod =
|
||||
connection.javaClass.getMethod(disconnectMethodName, getNMSClass("net.minecraft.network.chat.Component"))
|
||||
val quitMessageNoColour = "$name left the game"
|
||||
val disconnectMessage = getNMSComponent(quitMessageNoColour)
|
||||
disconnectMethod.invoke(connection, disconnectMessage)
|
||||
}
|
||||
|
||||
private fun scheduleServerPlayerTick() {
|
||||
if (isLegacy9 || isLegacy12) return
|
||||
val serverPlayerClass = serverPlayer.javaClass
|
||||
val tickMethod = resolveServerPlayerTickMethod(serverPlayerClass)
|
||||
val baseTickMethod = resolveBaseTickMethod(serverPlayerClass)
|
||||
val getRemainingFireTicksMethod = runCatching {
|
||||
val entityClass = getNMSClass("net.minecraft.world.entity.Entity")
|
||||
val remapped = reflectionRemapper.remapMethodName(entityClass, "getRemainingFireTicks")
|
||||
Reflector.getMethod(entityClass, remapped) ?: Reflector.getMethod(entityClass, "getRemainingFireTicks")
|
||||
}.getOrNull()
|
||||
tickTaskId = Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, Runnable {
|
||||
val remainingFireTicks = if (getRemainingFireTicksMethod != null) {
|
||||
runCatching { getRemainingFireTicksMethod.invoke(serverPlayer) as Int }.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (remainingFireTicks != null && remainingFireTicks > 0) {
|
||||
val player = bukkitPlayer
|
||||
val inWater = player != null && (
|
||||
player.location.block.type == Material.WATER ||
|
||||
player.eyeLocation.block.type == Material.WATER
|
||||
)
|
||||
if (inWater && tickMethod != null) {
|
||||
runCatching { tickMethod.invoke(serverPlayer) }
|
||||
return@Runnable
|
||||
}
|
||||
if (baseTickMethod != null) {
|
||||
runCatching { baseTickMethod.invoke(serverPlayer) }
|
||||
return@Runnable
|
||||
}
|
||||
}
|
||||
|
||||
if (tickMethod != null) {
|
||||
runCatching { tickMethod.invoke(serverPlayer) }
|
||||
} else if (baseTickMethod != null) {
|
||||
runCatching { baseTickMethod.invoke(serverPlayer) }
|
||||
}
|
||||
}, 1L, 1L)
|
||||
}
|
||||
|
||||
private fun resolveServerPlayerTickMethod(serverPlayerClass: Class<*>): Method? {
|
||||
val candidateNames = listOf("doTick", "tick")
|
||||
for (name in candidateNames) {
|
||||
val remapped = reflectionRemapper.remapMethodName(serverPlayerClass, name)
|
||||
val method = Reflector.getMethod(serverPlayerClass, remapped) ?: Reflector.getMethod(serverPlayerClass, name)
|
||||
if (method != null && method.parameterCount == 0) {
|
||||
method.isAccessible = true
|
||||
return method
|
||||
}
|
||||
}
|
||||
|
||||
val baseTickMethod = resolveBaseTickMethod(serverPlayerClass)
|
||||
if (baseTickMethod != null) {
|
||||
return baseTickMethod
|
||||
}
|
||||
|
||||
val fallback = serverPlayerClass.methods.firstOrNull { method ->
|
||||
method.parameterCount == 0 &&
|
||||
method.returnType == Void.TYPE &&
|
||||
method.name.lowercase().contains("tick")
|
||||
} ?: serverPlayerClass.methods.firstOrNull { method ->
|
||||
method.parameterCount == 0 && method.returnType == Void.TYPE
|
||||
}
|
||||
|
||||
fallback?.isAccessible = true
|
||||
return fallback
|
||||
}
|
||||
|
||||
private fun resolveBaseTickMethod(serverPlayerClass: Class<*>): Method? {
|
||||
val entityClass = runCatching { getNMSClass("net.minecraft.world.entity.Entity") }.getOrNull()
|
||||
val candidateNames = buildList {
|
||||
entityClass?.let { add(reflectionRemapper.remapMethodName(it, "baseTick")) }
|
||||
add("baseTick")
|
||||
}.distinct()
|
||||
for (name in candidateNames) {
|
||||
val method = (serverPlayerClass.declaredMethods + serverPlayerClass.methods).firstOrNull { candidate ->
|
||||
candidate.name == name && candidate.parameterCount == 0
|
||||
} ?: entityClass?.let { clazz ->
|
||||
(clazz.declaredMethods + clazz.methods).firstOrNull { candidate ->
|
||||
candidate.name == name && candidate.parameterCount == 0
|
||||
}
|
||||
}
|
||||
if (method != null) {
|
||||
method.isAccessible = true
|
||||
return method
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getNMSComponent(message: String): Any {
|
||||
// Convert a String to an NMS Component
|
||||
val componentClass = getNMSClass("net.minecraft.network.chat.Component")
|
||||
val literalName = reflectionRemapper.remapMethodName(componentClass, "literal", String::class.java)
|
||||
val componentMethod = checkNotNull(Reflector.getMethod(componentClass, literalName, "String"))
|
||||
return componentMethod.invoke(null, message)
|
||||
}
|
||||
|
||||
private fun getMinecraftServer(): Any {
|
||||
val server = Bukkit.getServer()
|
||||
val craftServerClass = server.javaClass
|
||||
val getServerMethod = Reflector.getMethod(craftServerClass, "getServer")
|
||||
?: throw NoSuchMethodException("Cannot find getServer method in ${craftServerClass.name}")
|
||||
return Reflector.invokeMethod(getServerMethod, server)
|
||||
}
|
||||
|
||||
private fun getPlayerList(minecraftServer: Any): Any {
|
||||
val playerListFieldName = reflectionRemapper.remapMethodName(minecraftServer.javaClass, "getPlayerList")
|
||||
val playerListMethod = checkNotNull(Reflector.getMethod(minecraftServer.javaClass, playerListFieldName))
|
||||
return Reflector.invokeMethod(playerListMethod, minecraftServer)
|
||||
}
|
||||
|
||||
private fun isPlayerListed(playerList: Any): Boolean {
|
||||
val playerListClass = getNMSClass("net.minecraft.server.players.PlayerList")
|
||||
return runCatching {
|
||||
val playersByUUIDField = Reflector.getMapFieldWithTypes(
|
||||
playerListClass,
|
||||
UUID::class.java,
|
||||
serverPlayer.javaClass
|
||||
)
|
||||
@Suppress("UNCHECKED_CAST") // Reflection into NMS map; types vary by version.
|
||||
val playerByUUID = Reflector.getFieldValue(playersByUUIDField, playerList) as Map<UUID, Any>
|
||||
playerByUUID.containsKey(uuid)
|
||||
}.getOrElse {
|
||||
val getPlayerMethodName = reflectionRemapper.remapMethodName(playerListClass, "getPlayer", UUID::class.java)
|
||||
val getPlayerMethod = Reflector.getMethodAssignable(
|
||||
playerListClass,
|
||||
getPlayerMethodName,
|
||||
UUID::class.java
|
||||
) ?: Reflector.getMethodAssignable(playerListClass, "getPlayer", UUID::class.java)
|
||||
if (getPlayerMethod != null) {
|
||||
Reflector.invokeMethod<Any?>(getPlayerMethod, playerList, uuid) != null
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isEntityRemoved(entity: Any): Boolean {
|
||||
val entityClass = getNMSClass("net.minecraft.world.entity.Entity")
|
||||
val isRemovedMethodName = reflectionRemapper.remapMethodName(entityClass, "isRemoved")
|
||||
val isRemovedMethod = Reflector.getMethod(entityClass, isRemovedMethodName)
|
||||
?: Reflector.getMethod(entityClass, "isRemoved")
|
||||
if (isRemovedMethod != null) {
|
||||
return Reflector.invokeMethod(isRemovedMethod, entity)
|
||||
}
|
||||
return runCatching {
|
||||
val removedFieldName = reflectionRemapper.remapFieldName(entityClass, "removed")
|
||||
val removedField = Reflector.getField(entityClass, removedFieldName)
|
||||
removedField.getBoolean(entity)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
fun attack(bukkitEntity: Entity) {
|
||||
attackCompat(checkNotNull(bukkitPlayer), bukkitEntity)
|
||||
}
|
||||
|
||||
fun doBlocking() {
|
||||
if (isLegacy9 || isLegacy12) {
|
||||
// Legacy blocking: ensure sword in hand + shield in offhand, then raise offhand.
|
||||
bukkitPlayer!!.inventory.setItemInMainHand(org.bukkit.inventory.ItemStack(Material.DIAMOND_SWORD))
|
||||
if (bukkitPlayer!!.inventory.itemInOffHand.type != Material.SHIELD) {
|
||||
bukkitPlayer!!.inventory.setItemInOffHand(org.bukkit.inventory.ItemStack(Material.SHIELD))
|
||||
}
|
||||
bukkitPlayer!!.updateInventory()
|
||||
if (isLegacy12) legacyImpl12?.startUsingOffhand()
|
||||
return
|
||||
}
|
||||
bukkitPlayer!!.inventory.setItemInMainHand(org.bukkit.inventory.ItemStack(Material.SHIELD))
|
||||
|
||||
val livingEntityClass = getNMSClass("net.minecraft.world.entity.LivingEntity")
|
||||
|
||||
// Start using item (simulate blocking)
|
||||
val interactionHandClass = getNMSClass("net.minecraft.world.InteractionHand")
|
||||
val mainHandFieldName = reflectionRemapper.remapFieldName(interactionHandClass, "MAIN_HAND")
|
||||
val mainHandField = interactionHandClass.getDeclaredField(mainHandFieldName)
|
||||
val mainHand = mainHandField.get(null)
|
||||
|
||||
val startUsingItemMethodName = reflectionRemapper.remapMethodName(
|
||||
livingEntityClass,
|
||||
"startUsingItem",
|
||||
interactionHandClass
|
||||
)
|
||||
|
||||
val startUsingItemMethod = checkNotNull(
|
||||
Reflector.getMethod(livingEntityClass, startUsingItemMethodName, interactionHandClass.simpleName)
|
||||
)
|
||||
Reflector.invokeMethod<Any>(startUsingItemMethod, serverPlayer, mainHand)
|
||||
|
||||
// Manually set useItemRemaining field to simulate blocking
|
||||
val useItemRemainingFieldName = reflectionRemapper.remapFieldName(livingEntityClass, "useItemRemaining")
|
||||
val useItemRemainingField = livingEntityClass.getDeclaredField(useItemRemainingFieldName)
|
||||
useItemRemainingField.isAccessible = true
|
||||
useItemRemainingField.setInt(serverPlayer, 200)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.doubles.shouldBeLessThan
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleFishingRodVelocity
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
|
||||
import kotlinx.coroutines.delay
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.entity.Entity
|
||||
import org.bukkit.entity.FishHook
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.player.PlayerFishEvent
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import org.bukkit.util.Vector
|
||||
import java.util.Random
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Callable
|
||||
import java.lang.reflect.InvocationHandler
|
||||
import java.lang.reflect.Proxy
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class FishingRodVelocityIntegrationTest : FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val module = ModuleLoader.getModules()
|
||||
.filterIsInstance<ModuleFishingRodVelocity>()
|
||||
.firstOrNull() ?: error("ModuleFishingRodVelocity not registered")
|
||||
|
||||
lateinit var player: Player
|
||||
lateinit var fakePlayer: FakePlayer
|
||||
|
||||
fun <T> runSync(action: () -> T): T {
|
||||
return if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
|
||||
action()
|
||||
}).get()
|
||||
}
|
||||
|
||||
fun setModeset(player: Player, modeset: String) {
|
||||
val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
|
||||
playerData.setModesetForWorld(player.world.uid, modeset)
|
||||
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)
|
||||
}
|
||||
|
||||
fun setModuleRandomSeed(seed: Long) {
|
||||
val field = module.javaClass.getDeclaredField("random")
|
||||
field.isAccessible = true
|
||||
field.set(module, Random(seed))
|
||||
}
|
||||
|
||||
fun assertVectorClose(actual: Vector, expected: Vector, tolerance: Double) {
|
||||
abs(actual.x - expected.x) shouldBeLessThan tolerance
|
||||
abs(actual.y - expected.y) shouldBeLessThan tolerance
|
||||
abs(actual.z - expected.z) shouldBeLessThan tolerance
|
||||
}
|
||||
|
||||
fun createFishEvent(player: Player, hook: FishHook, state: PlayerFishEvent.State): PlayerFishEvent {
|
||||
val ctors = PlayerFishEvent::class.java.constructors
|
||||
for (ctor in ctors) {
|
||||
val paramTypes = ctor.parameterTypes
|
||||
val hasFishHookParam = paramTypes.any { FishHook::class.java.isAssignableFrom(it) }
|
||||
val args = arrayOfNulls<Any>(paramTypes.size)
|
||||
var ok = true
|
||||
var hookAssigned = false
|
||||
|
||||
for (i in paramTypes.indices) {
|
||||
val t = paramTypes[i]
|
||||
args[i] = when {
|
||||
Player::class.java.isAssignableFrom(t) -> player
|
||||
FishHook::class.java.isAssignableFrom(t) -> {
|
||||
hookAssigned = true
|
||||
hook
|
||||
}
|
||||
Entity::class.java.isAssignableFrom(t) -> {
|
||||
if (hasFishHookParam) {
|
||||
// Treat as "caught" entity slot in modern signatures
|
||||
null
|
||||
} else if (!hookAssigned) {
|
||||
// Legacy signatures sometimes use Entity for the hook
|
||||
hookAssigned = true
|
||||
hook
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
PlayerFishEvent.State::class.java.isAssignableFrom(t) -> state
|
||||
t == Int::class.javaPrimitiveType -> 0
|
||||
t == Boolean::class.javaPrimitiveType -> false
|
||||
t.isEnum && t.name.endsWith("EquipmentSlot") -> {
|
||||
// Prefer main-hand if present (modern signature)
|
||||
runCatching { java.lang.Enum.valueOf(t as Class<out Enum<*>>, "HAND") }.getOrNull()
|
||||
}
|
||||
ItemStack::class.java.isAssignableFrom(t) -> ItemStack(Material.FISHING_ROD)
|
||||
else -> null
|
||||
}
|
||||
|
||||
// If we failed to provide a value for a primitive, this ctor won't work
|
||||
if (args[i] == null && t.isPrimitive) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) continue
|
||||
try {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val event = ctor.newInstance(*args) as PlayerFishEvent
|
||||
|
||||
// Validate that the event reports the expected hook; legacy signatures vary.
|
||||
val hookObj = runCatching {
|
||||
PlayerFishEvent::class.java.getMethod("getHook").invoke(event)
|
||||
}.getOrNull()
|
||||
val hookFromEvent = hookObj as? FishHook ?: continue
|
||||
if (hookFromEvent.uniqueId != hook.uniqueId) continue
|
||||
|
||||
return event
|
||||
} catch (_: Throwable) {
|
||||
// Try next
|
||||
}
|
||||
}
|
||||
error("No compatible PlayerFishEvent constructor found for this server version")
|
||||
}
|
||||
|
||||
fun createFakeHook(player: Player): FishHook {
|
||||
val id = UUID.randomUUID()
|
||||
var velocity = Vector(0.0, 0.0, 0.0)
|
||||
val handler = InvocationHandler { _, method, args ->
|
||||
when (method.name) {
|
||||
"getUniqueId" -> return@InvocationHandler id
|
||||
"getVelocity" -> return@InvocationHandler velocity
|
||||
"setVelocity" -> {
|
||||
velocity = (args?.get(0) as? Vector) ?: velocity
|
||||
return@InvocationHandler null
|
||||
}
|
||||
"isValid" -> return@InvocationHandler true
|
||||
"remove" -> return@InvocationHandler null
|
||||
"getWorld" -> return@InvocationHandler player.world
|
||||
"getLocation" -> return@InvocationHandler player.location.clone()
|
||||
}
|
||||
|
||||
return@InvocationHandler when (method.returnType) {
|
||||
java.lang.Boolean.TYPE -> false
|
||||
java.lang.Integer.TYPE -> 0
|
||||
java.lang.Long.TYPE -> 0L
|
||||
java.lang.Float.TYPE -> 0f
|
||||
java.lang.Double.TYPE -> 0.0
|
||||
java.lang.Void.TYPE -> null
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
val interfaces = mutableListOf<Class<*>>(FishHook::class.java)
|
||||
runCatching { Class.forName("org.bukkit.entity.Fish") }
|
||||
.getOrNull()
|
||||
?.takeIf { it.isInterface }
|
||||
?.let { interfaces.add(it) }
|
||||
return Proxy.newProxyInstance(
|
||||
FishHook::class.java.classLoader,
|
||||
interfaces.toTypedArray(),
|
||||
handler
|
||||
) as FishHook
|
||||
}
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
beforeSpec {
|
||||
runSync {
|
||||
val world = Bukkit.getWorld("world") ?: error("world not loaded")
|
||||
val location = Location(world, 0.0, 120.0, 0.0, 45f, 10f)
|
||||
fakePlayer = FakePlayer(testPlugin)
|
||||
fakePlayer.spawn(location)
|
||||
player = Bukkit.getPlayer(fakePlayer.uuid) ?: error("Player not found")
|
||||
setModeset(player, "old")
|
||||
module.reload()
|
||||
}
|
||||
}
|
||||
|
||||
afterSpec {
|
||||
runSync { fakePlayer.removePlayer() }
|
||||
}
|
||||
|
||||
beforeTest {
|
||||
runSync {
|
||||
setModeset(player, "old")
|
||||
module.reload()
|
||||
player.inventory.setItemInMainHand(ItemStack(Material.FISHING_ROD))
|
||||
}
|
||||
}
|
||||
|
||||
test("sets hook velocity to the 1.8 formula (deterministic random seed)") {
|
||||
val hook = if (Reflector.versionIsNewerOrEqualTo(1, 14, 0)) {
|
||||
runSync { player.launchProjectile(FishHook::class.java) }
|
||||
} else {
|
||||
createFakeHook(player)
|
||||
}
|
||||
try {
|
||||
runSync {
|
||||
// Keep everything in a single main-thread slice so hook physics cannot tick between steps (legacy servers are sensitive).
|
||||
setModuleRandomSeed(0)
|
||||
|
||||
val event = createFishEvent(player, hook, PlayerFishEvent.State.FISHING)
|
||||
module.onFishEvent(event)
|
||||
|
||||
// Mirror the module's float-heavy computation so Java 8/legacy servers match precisely.
|
||||
val yaw = player.location.yaw.toDouble()
|
||||
val pitch = player.location.pitch.toDouble()
|
||||
|
||||
val oldMaxVelocity = 0.4f.toDouble()
|
||||
val piF = Math.PI.toFloat().toDouble()
|
||||
val degF = 180.0f.toDouble()
|
||||
|
||||
var vx = -sin(yaw / degF * piF) * cos(pitch / degF * piF) * oldMaxVelocity
|
||||
var vz = cos(yaw / degF * piF) * cos(pitch / degF * piF) * oldMaxVelocity
|
||||
var vy = -sin(pitch / degF * piF) * oldMaxVelocity
|
||||
|
||||
val oldVelocityMultiplier = 1.5
|
||||
val vectorLength = sqrt(vx * vx + vy * vy + vz * vz).toFloat().toDouble()
|
||||
vx /= vectorLength
|
||||
vy /= vectorLength
|
||||
vz /= vectorLength
|
||||
|
||||
val rng = Random(0)
|
||||
vx += rng.nextGaussian() * 0.007499999832361937
|
||||
vy += rng.nextGaussian() * 0.007499999832361937
|
||||
vz += rng.nextGaussian() * 0.007499999832361937
|
||||
|
||||
vx *= oldVelocityMultiplier
|
||||
vy *= oldVelocityMultiplier
|
||||
vz *= oldVelocityMultiplier
|
||||
|
||||
val expected = Vector(vx, vy, vz)
|
||||
val actual = hook.velocity
|
||||
assertVectorClose(actual, expected, 1e-6)
|
||||
}
|
||||
} finally {
|
||||
if (Reflector.versionIsNewerOrEqualTo(1, 14, 0)) {
|
||||
runSync { hook.remove() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("does not modify hook velocity for non-FISHING states") {
|
||||
val hook = if (Reflector.versionIsNewerOrEqualTo(1, 14, 0)) {
|
||||
runSync { player.launchProjectile(FishHook::class.java) }
|
||||
} else {
|
||||
createFakeHook(player)
|
||||
}
|
||||
try {
|
||||
val nonFishing = PlayerFishEvent.State.values().firstOrNull { it != PlayerFishEvent.State.FISHING }
|
||||
?: error("No non-FISHING PlayerFishEvent state available")
|
||||
|
||||
runSync {
|
||||
val original = Vector(0.123, 0.456, -0.789)
|
||||
hook.velocity = original
|
||||
val baseline = hook.velocity
|
||||
|
||||
val event = createFishEvent(player, hook, nonFishing)
|
||||
module.onFishEvent(event)
|
||||
|
||||
val actual = hook.velocity
|
||||
assertVectorClose(actual, baseline, 1e-8)
|
||||
}
|
||||
} finally {
|
||||
if (Reflector.versionIsNewerOrEqualTo(1, 14, 0)) {
|
||||
runSync { hook.remove() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("1.14+ applies an extra gravity tick when the hook is not in water") {
|
||||
if (!Reflector.versionIsNewerOrEqualTo(1, 14, 0)) return@test
|
||||
|
||||
val hookBaseline = runSync { player.launchProjectile(FishHook::class.java) }
|
||||
val hookWithModule = runSync { player.launchProjectile(FishHook::class.java) }
|
||||
try {
|
||||
runSync {
|
||||
// Keep both hooks colocated so vanilla physics is as close as possible
|
||||
hookWithModule.teleport(hookBaseline.location)
|
||||
|
||||
// Schedule the module's per-tick gravity adjustment for hookWithModule
|
||||
val event = createFishEvent(player, hookWithModule, PlayerFishEvent.State.FISHING)
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
|
||||
// Force the same starting velocity for both so we can compare deltas
|
||||
val start = Vector(0.0, 0.2, 0.0)
|
||||
hookBaseline.velocity = start
|
||||
hookWithModule.velocity = start
|
||||
}
|
||||
|
||||
// Wait for at least one tick of the module's gravity adjustment
|
||||
delay(3 * 50L)
|
||||
|
||||
val yBaseline = runSync { hookBaseline.velocity.y }
|
||||
val yWithModule = runSync { hookWithModule.velocity.y }
|
||||
|
||||
// Module subtracts an extra 0.01 from Y each tick (when not in water),
|
||||
// so it should be noticeably more negative than the baseline hook.
|
||||
(yWithModule < yBaseline - 0.005) shouldBe true
|
||||
} finally {
|
||||
runSync {
|
||||
hookBaseline.remove()
|
||||
hookWithModule.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,382 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import com.cryptomorin.xseries.XAttribute
|
||||
import com.cryptomorin.xseries.XMaterial
|
||||
import com.cryptomorin.xseries.XPotion
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.core.test.TestScope
|
||||
import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual
|
||||
import io.kotest.matchers.ints.shouldBeLessThanOrEqual
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.shouldNotBe
|
||||
import io.kotest.matchers.longs.shouldBeBetween
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleGoldenApple
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.attribute.Attribute
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.inventory.PrepareItemCraftEvent
|
||||
import org.bukkit.event.player.PlayerItemConsumeEvent
|
||||
import org.bukkit.inventory.CraftingInventory
|
||||
import org.bukkit.inventory.EquipmentSlot
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import org.bukkit.potion.PotionEffect
|
||||
import org.bukkit.potion.PotionEffectType
|
||||
import kotlin.coroutines.resume
|
||||
import kernitus.plugin.OldCombatMechanics.TesterUtils.getPotionEffectCompat
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class GoldenAppleIntegrationTest : FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
val module = ModuleGoldenApple.getInstance()
|
||||
lateinit var player: Player
|
||||
lateinit var fakePlayer: FakePlayer
|
||||
|
||||
suspend fun TestScope.withConfig(block: suspend TestScope.() -> Unit) {
|
||||
val oldPotionEffects = ocm.config.getBoolean("old-golden-apples.old-potion-effects")
|
||||
val normalCooldown = ocm.config.getLong("old-golden-apples.cooldown.normal")
|
||||
val enchantedCooldown = ocm.config.getLong("old-golden-apples.cooldown.enchanted")
|
||||
val sharedCooldown = ocm.config.getBoolean("old-golden-apples.cooldown.is-shared")
|
||||
val crafting = ocm.config.getBoolean("old-golden-apples.enchanted-golden-apple-crafting")
|
||||
val noConflict = ocm.config.getBoolean("old-golden-apples.no-conflict-mode")
|
||||
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
ocm.config.set("old-golden-apples.old-potion-effects", oldPotionEffects)
|
||||
ocm.config.set("old-golden-apples.cooldown.normal", normalCooldown)
|
||||
ocm.config.set("old-golden-apples.cooldown.enchanted", enchantedCooldown)
|
||||
ocm.config.set("old-golden-apples.cooldown.is-shared", sharedCooldown)
|
||||
ocm.config.set("old-golden-apples.enchanted-golden-apple-crafting", crafting)
|
||||
ocm.config.set("old-golden-apples.no-conflict-mode", noConflict)
|
||||
module.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
}
|
||||
}
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
fun runSync(action: () -> Unit) {
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit.getScheduler().callSyncMethod(testPlugin) {
|
||||
action()
|
||||
null
|
||||
}.get()
|
||||
}
|
||||
}
|
||||
|
||||
fun setModeset(modeset: String) {
|
||||
val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
|
||||
playerData.setModesetForWorld(player.world.uid, modeset)
|
||||
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)
|
||||
}
|
||||
|
||||
fun callConsume(item: ItemStack): PlayerItemConsumeEvent {
|
||||
val ctor = PlayerItemConsumeEvent::class.java.constructors.firstOrNull { constructor ->
|
||||
val params = constructor.parameterTypes
|
||||
params.size == 3 &&
|
||||
Player::class.java.isAssignableFrom(params[0]) &&
|
||||
ItemStack::class.java.isAssignableFrom(params[1]) &&
|
||||
EquipmentSlot::class.java.isAssignableFrom(params[2])
|
||||
}
|
||||
val event = if (ctor != null) {
|
||||
ctor.newInstance(player, item, EquipmentSlot.HAND) as PlayerItemConsumeEvent
|
||||
} else {
|
||||
PlayerItemConsumeEvent(player, item)
|
||||
}
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
fun prepareCraftResult(result: ItemStack): PrepareItemCraftEvent {
|
||||
val openWorkbench = player.javaClass.getMethod(
|
||||
"openWorkbench",
|
||||
Location::class.java,
|
||||
Boolean::class.javaPrimitiveType
|
||||
)
|
||||
val viewObj = openWorkbench.invoke(player, null, true) ?: error("Workbench view was null")
|
||||
val getTopInventory = viewObj.javaClass.getMethod("getTopInventory")
|
||||
val inventory = getTopInventory.invoke(viewObj) as CraftingInventory
|
||||
inventory.result = result
|
||||
|
||||
val ctor = PrepareItemCraftEvent::class.java.constructors.firstOrNull { constructor ->
|
||||
val params = constructor.parameterTypes
|
||||
params.size == 3 &&
|
||||
CraftingInventory::class.java.isAssignableFrom(params[0]) &&
|
||||
params[2] == Boolean::class.javaPrimitiveType
|
||||
} ?: error("PrepareItemCraftEvent constructor not found")
|
||||
return ctor.newInstance(inventory, viewObj, false) as PrepareItemCraftEvent
|
||||
}
|
||||
|
||||
fun assertDuration(effect: PotionEffect?, expectedTicks: Int) {
|
||||
effect.shouldNotBe(null)
|
||||
val duration = effect!!.duration
|
||||
duration.shouldBeGreaterThanOrEqual(expectedTicks - 10)
|
||||
duration.shouldBeLessThanOrEqual(expectedTicks)
|
||||
}
|
||||
|
||||
suspend fun waitForEffects(ticks: Long = 2L) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
Bukkit.getScheduler().runTaskLater(testPlugin, Runnable {
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(Unit)
|
||||
}
|
||||
}, ticks)
|
||||
}
|
||||
}
|
||||
|
||||
fun maxHealthAttribute(): Attribute {
|
||||
return XAttribute.MAX_HEALTH.get() ?: error("Max health attribute not available")
|
||||
}
|
||||
|
||||
fun enchantedAppleItem(): ItemStack {
|
||||
return XMaterial.ENCHANTED_GOLDEN_APPLE.parseItem()
|
||||
?: error("Enchanted golden apple item not available")
|
||||
}
|
||||
|
||||
beforeSpec {
|
||||
runSync {
|
||||
val world = Bukkit.getServer().getWorld("world")
|
||||
val location = Location(world, 0.0, 100.0, 0.0)
|
||||
|
||||
fakePlayer = FakePlayer(testPlugin)
|
||||
fakePlayer.spawn(location)
|
||||
|
||||
player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))
|
||||
player.gameMode = GameMode.SURVIVAL
|
||||
player.maximumNoDamageTicks = 20
|
||||
player.noDamageTicks = 0
|
||||
player.isInvulnerable = false
|
||||
player.isOp = true
|
||||
setModeset("old")
|
||||
}
|
||||
}
|
||||
|
||||
afterSpec {
|
||||
runSync {
|
||||
fakePlayer.removePlayer()
|
||||
}
|
||||
}
|
||||
|
||||
beforeTest {
|
||||
runSync {
|
||||
player.inventory.clear()
|
||||
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
|
||||
player.health = player.getAttribute(maxHealthAttribute())!!.value
|
||||
player.foodLevel = 20
|
||||
setModeset("old")
|
||||
module.reload()
|
||||
}
|
||||
}
|
||||
|
||||
context("Potion Effects") {
|
||||
test("golden apple applies configured effects") {
|
||||
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
|
||||
callConsume(player.inventory.itemInMainHand)
|
||||
waitForEffects()
|
||||
|
||||
val regeneration = player.getPotionEffectCompat(PotionEffectType.REGENERATION)
|
||||
val absorption = player.getPotionEffectCompat(PotionEffectType.ABSORPTION)
|
||||
|
||||
assertDuration(regeneration, 5 * 20)
|
||||
regeneration?.amplifier shouldBe 1
|
||||
assertDuration(absorption, 120 * 20)
|
||||
absorption?.amplifier shouldBe 0
|
||||
}
|
||||
|
||||
test("enchanted golden apple applies configured effects") {
|
||||
player.inventory.setItemInMainHand(enchantedAppleItem())
|
||||
callConsume(player.inventory.itemInMainHand)
|
||||
waitForEffects()
|
||||
|
||||
val regeneration = player.getPotionEffectCompat(PotionEffectType.REGENERATION)
|
||||
val absorption = player.getPotionEffectCompat(PotionEffectType.ABSORPTION)
|
||||
val resistance = player.getPotionEffectCompat(XPotion.RESISTANCE.get()!!)
|
||||
val fireResistance = player.getPotionEffectCompat(PotionEffectType.FIRE_RESISTANCE)
|
||||
|
||||
assertDuration(regeneration, 30 * 20)
|
||||
regeneration?.amplifier shouldBe 4
|
||||
assertDuration(absorption, 120 * 20)
|
||||
absorption?.amplifier shouldBe 0
|
||||
assertDuration(resistance, 300 * 20)
|
||||
resistance?.amplifier shouldBe 0
|
||||
assertDuration(fireResistance, 300 * 20)
|
||||
fireResistance?.amplifier shouldBe 0
|
||||
}
|
||||
|
||||
test("higher amplifier replaces existing effect") {
|
||||
player.addPotionEffect(PotionEffect(PotionEffectType.REGENERATION, 100, 0))
|
||||
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
|
||||
|
||||
callConsume(player.inventory.itemInMainHand)
|
||||
waitForEffects()
|
||||
|
||||
val regeneration = player.getPotionEffectCompat(PotionEffectType.REGENERATION)
|
||||
regeneration?.amplifier shouldBe 1
|
||||
assertDuration(regeneration, 5 * 20)
|
||||
}
|
||||
|
||||
test("same amplifier with longer duration refreshes effect") {
|
||||
player.addPotionEffect(PotionEffect(PotionEffectType.REGENERATION, 50, 1))
|
||||
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
|
||||
|
||||
callConsume(player.inventory.itemInMainHand)
|
||||
waitForEffects()
|
||||
|
||||
val regeneration = player.getPotionEffectCompat(PotionEffectType.REGENERATION)
|
||||
regeneration?.amplifier shouldBe 1
|
||||
assertDuration(regeneration, 5 * 20)
|
||||
}
|
||||
|
||||
test("lower amplifier does not override existing effect") {
|
||||
player.addPotionEffect(PotionEffect(PotionEffectType.REGENERATION, 100, 3))
|
||||
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
|
||||
|
||||
callConsume(player.inventory.itemInMainHand)
|
||||
waitForEffects()
|
||||
|
||||
val regeneration = player.getPotionEffectCompat(PotionEffectType.REGENERATION)
|
||||
regeneration?.amplifier shouldBe 3
|
||||
}
|
||||
|
||||
test("old-potion-effects disabled leaves effects unchanged") {
|
||||
withConfig {
|
||||
ocm.config.set("old-golden-apples.old-potion-effects", false)
|
||||
module.reload()
|
||||
|
||||
player.addPotionEffect(PotionEffect(PotionEffectType.SPEED, 200, 0))
|
||||
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
|
||||
callConsume(player.inventory.itemInMainHand)
|
||||
waitForEffects()
|
||||
|
||||
player.getPotionEffectCompat(PotionEffectType.SPEED).shouldNotBe(null)
|
||||
player.getPotionEffectCompat(PotionEffectType.REGENERATION).shouldBe(null)
|
||||
player.getPotionEffectCompat(PotionEffectType.ABSORPTION).shouldBe(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context("Cooldowns") {
|
||||
test("repeated consumption is blocked by cooldown") {
|
||||
withConfig {
|
||||
ocm.config.set("old-golden-apples.cooldown.normal", 60)
|
||||
ocm.config.set("old-golden-apples.cooldown.enchanted", 0)
|
||||
ocm.config.set("old-golden-apples.cooldown.is-shared", false)
|
||||
module.reload()
|
||||
|
||||
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
|
||||
val first = callConsume(player.inventory.itemInMainHand)
|
||||
first.isCancelled shouldBe false
|
||||
|
||||
val second = callConsume(player.inventory.itemInMainHand)
|
||||
second.isCancelled shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
test("shared cooldown blocks other apple type") {
|
||||
withConfig {
|
||||
ocm.config.set("old-golden-apples.cooldown.normal", 60)
|
||||
ocm.config.set("old-golden-apples.cooldown.enchanted", 60)
|
||||
ocm.config.set("old-golden-apples.cooldown.is-shared", true)
|
||||
module.reload()
|
||||
|
||||
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
|
||||
callConsume(player.inventory.itemInMainHand)
|
||||
|
||||
player.inventory.setItemInMainHand(enchantedAppleItem())
|
||||
val enchantedEvent = callConsume(player.inventory.itemInMainHand)
|
||||
enchantedEvent.isCancelled shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
test("separate cooldown allows other apple type") {
|
||||
withConfig {
|
||||
ocm.config.set("old-golden-apples.cooldown.normal", 60)
|
||||
ocm.config.set("old-golden-apples.cooldown.enchanted", 60)
|
||||
ocm.config.set("old-golden-apples.cooldown.is-shared", false)
|
||||
module.reload()
|
||||
|
||||
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
|
||||
callConsume(player.inventory.itemInMainHand)
|
||||
|
||||
player.inventory.setItemInMainHand(enchantedAppleItem())
|
||||
val enchantedEvent = callConsume(player.inventory.itemInMainHand)
|
||||
enchantedEvent.isCancelled shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
test("cooldown getters return remaining seconds") {
|
||||
withConfig {
|
||||
ocm.config.set("old-golden-apples.cooldown.normal", 5)
|
||||
ocm.config.set("old-golden-apples.cooldown.enchanted", 5)
|
||||
ocm.config.set("old-golden-apples.cooldown.is-shared", false)
|
||||
module.reload()
|
||||
|
||||
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
|
||||
callConsume(player.inventory.itemInMainHand)
|
||||
module.getGappleCooldown(player.uniqueId).shouldBeBetween(1, 5)
|
||||
|
||||
player.inventory.setItemInMainHand(enchantedAppleItem())
|
||||
callConsume(player.inventory.itemInMainHand)
|
||||
module.getNappleCooldown(player.uniqueId).shouldBeBetween(1, 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context("Crafting") {
|
||||
test("enchanted golden apple crafting is blocked when disabled") {
|
||||
withConfig {
|
||||
ocm.config.set("old-golden-apples.enchanted-golden-apple-crafting", false)
|
||||
ocm.config.set("old-golden-apples.no-conflict-mode", false)
|
||||
module.reload()
|
||||
|
||||
val event = prepareCraftResult(enchantedAppleItem())
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
event.inventory.result.shouldBe(null)
|
||||
player.closeInventory()
|
||||
}
|
||||
}
|
||||
|
||||
test("no-conflict-mode preserves crafting result") {
|
||||
withConfig {
|
||||
ocm.config.set("old-golden-apples.enchanted-golden-apple-crafting", false)
|
||||
ocm.config.set("old-golden-apples.no-conflict-mode", true)
|
||||
module.reload()
|
||||
|
||||
val event = prepareCraftResult(enchantedAppleItem())
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
event.inventory.result.shouldNotBe(null)
|
||||
player.closeInventory()
|
||||
}
|
||||
}
|
||||
|
||||
test("unknown modeset disables crafting") {
|
||||
withConfig {
|
||||
ocm.config.set("old-golden-apples.enchanted-golden-apple-crafting", true)
|
||||
ocm.config.set("old-golden-apples.no-conflict-mode", false)
|
||||
module.reload()
|
||||
setModeset("missing")
|
||||
|
||||
val event = prepareCraftResult(enchantedAppleItem())
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
event.inventory.result.shouldBe(null)
|
||||
player.closeInventory()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,420 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import com.cryptomorin.xseries.XAttribute
|
||||
import com.cryptomorin.xseries.XEnchantment
|
||||
import com.cryptomorin.xseries.XPotion
|
||||
import kernitus.plugin.OldCombatMechanics.TesterUtils.assertEquals
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.damage.DamageUtils.getOldSharpnessDamage
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.damage.DamageUtils.isCriticalHit1_8
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.damage.DefenceUtils.getDamageAfterArmour1_8
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages.getDamage
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
|
||||
import kernitus.plugin.OldCombatMechanics.TesterUtils.getPotionEffectCompat
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.attribute.AttributeModifier
|
||||
import org.bukkit.command.CommandSender
|
||||
import org.bukkit.entity.LivingEntity
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.EventPriority
|
||||
import org.bukkit.event.Listener
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent
|
||||
import org.bukkit.event.entity.EntityDamageEvent
|
||||
import org.bukkit.inventory.EquipmentSlot
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import org.bukkit.potion.PotionEffect
|
||||
import java.util.*
|
||||
import java.util.function.Consumer
|
||||
import kotlin.math.max
|
||||
|
||||
class InGameTester(private val plugin: JavaPlugin) {
|
||||
private var tally: Tally? = null
|
||||
private var sender: CommandSender? = null
|
||||
private lateinit var attacker: Player
|
||||
private lateinit var defender: Player
|
||||
private lateinit var fakeAttacker: FakePlayer
|
||||
private lateinit var fakeDefender: FakePlayer
|
||||
private val testQueue: Queue<OCMTest> = ArrayDeque()
|
||||
|
||||
/**
|
||||
* Perform all tests using the two specified players
|
||||
*/
|
||||
fun performTests(sender: CommandSender?, location: Location) {
|
||||
plugin.logger.info("PERFORMING THE TESTS")
|
||||
this.sender = sender
|
||||
fakeAttacker = FakePlayer(plugin)
|
||||
plugin.logger.info("FAKE")
|
||||
fakeAttacker.spawn(location.add(2.0, 0.0, 0.0))
|
||||
plugin.logger.info("FAKE2")
|
||||
fakeDefender = FakePlayer(plugin)
|
||||
val defenderLocation = location.add(0.0, 0.0, 2.0)
|
||||
fakeDefender.spawn(defenderLocation)
|
||||
|
||||
attacker = checkNotNull(Bukkit.getPlayer(fakeAttacker.uuid))
|
||||
defender = checkNotNull(Bukkit.getPlayer(fakeDefender.uuid))
|
||||
|
||||
// Turn defender to face attacker
|
||||
defenderLocation.yaw = 180f
|
||||
defenderLocation.pitch = 0f
|
||||
defender.teleport(defenderLocation)
|
||||
|
||||
// modeset of attacker takes precedence
|
||||
var playerData = getPlayerData(attacker.uniqueId)
|
||||
playerData.setModesetForWorld(attacker.world.uid, "old")
|
||||
setPlayerData(attacker.uniqueId, playerData)
|
||||
|
||||
playerData = getPlayerData(defender.uniqueId)
|
||||
playerData.setModesetForWorld(defender.world.uid, "new")
|
||||
setPlayerData(defender.uniqueId, playerData)
|
||||
|
||||
beforeAll()
|
||||
tally = Tally()
|
||||
|
||||
// Queue all tests
|
||||
//runAttacks(new ItemStack[]{}, () -> {}); // with no armour
|
||||
testArmour()
|
||||
|
||||
//testEnchantedMelee(new ItemStack[]{}, () -> {});
|
||||
|
||||
// Run all tests in the queue
|
||||
runQueuedTests()
|
||||
}
|
||||
|
||||
private fun runAttacks(armour: Array<ItemStack>, preparations: Runnable) {
|
||||
//testMelee(armour, preparations);
|
||||
testEnchantedMelee(armour, preparations)
|
||||
testOverdamage(armour, preparations)
|
||||
}
|
||||
|
||||
private fun testArmour() {
|
||||
val materials = arrayOf("LEATHER", "CHAINMAIL", "GOLDEN", "IRON", "DIAMOND", "NETHERITE")
|
||||
val slots = arrayOf("BOOTS", "LEGGINGS", "CHESTPLATE", "HELMET")
|
||||
val random = Random(System.currentTimeMillis())
|
||||
|
||||
val armourContents = Array(4) { i ->
|
||||
val slot = slots[i]
|
||||
// Pick a random material for each slot
|
||||
val material = materials[random.nextInt(materials.size)]
|
||||
|
||||
val itemStack = ItemStack(Material.valueOf("${material}_$slot"))
|
||||
|
||||
// Apply enchantment to the armour piece
|
||||
itemStack.addUnsafeEnchantment(XEnchantment.PROTECTION.get()!!, 50)
|
||||
|
||||
itemStack
|
||||
}
|
||||
|
||||
runAttacks(armourContents) {
|
||||
defender.inventory.setArmorContents(armourContents)
|
||||
// Test status effects on defence: resistance, fire resistance, absorption
|
||||
defender.addPotionEffect(PotionEffect(XPotion.RESISTANCE.potionEffectType!!, 10, 1))
|
||||
fakeDefender.doBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
private fun testEnchantedMelee(armour: Array<ItemStack>, preparations: Runnable) {
|
||||
for (weaponType in kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages.getMaterialDamages().keys) {
|
||||
val weapon = ItemStack(weaponType)
|
||||
|
||||
// only axe and sword can have sharpness
|
||||
try {
|
||||
weapon.addEnchantment(XEnchantment.SHARPNESS.get()!!, 3)
|
||||
} catch (ignored: IllegalArgumentException) {
|
||||
}
|
||||
|
||||
val message = weaponType.name + " Sharpness 3"
|
||||
queueAttack(OCMTest(weapon, armour, 2, message) {
|
||||
preparations.run()
|
||||
defender.maximumNoDamageTicks = 0
|
||||
attacker.addPotionEffect(PotionEffect(XPotion.STRENGTH.get()!!, 10, 0, false))
|
||||
attacker.addPotionEffect(PotionEffect(XPotion.WEAKNESS.get()!!, 10, -1, false))
|
||||
plugin.logger.info("TESTING WEAPON $weaponType")
|
||||
attacker.fallDistance = 2f // Crit
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun testMelee(armour: Array<ItemStack>, preparations: Runnable) {
|
||||
for (weaponType in kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages.getMaterialDamages().keys) {
|
||||
val weapon = ItemStack(weaponType)
|
||||
queueAttack(OCMTest(weapon, armour, 1, weaponType.name) {
|
||||
preparations.run()
|
||||
defender.maximumNoDamageTicks = 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun testOverdamage(armour: Array<ItemStack>, preparations: Runnable) {
|
||||
// 1, 5, 6, 7, 3, 8 according to OCM
|
||||
// 1, 4, 5, 6, 2, 7 according to 1.9+
|
||||
val weapons = arrayOf(
|
||||
Material.WOODEN_HOE,
|
||||
Material.WOODEN_SWORD,
|
||||
Material.STONE_SWORD,
|
||||
Material.IRON_SWORD,
|
||||
Material.WOODEN_PICKAXE,
|
||||
Material.DIAMOND_SWORD
|
||||
)
|
||||
|
||||
for (weaponType in weapons) {
|
||||
val weapon = ItemStack(weaponType)
|
||||
queueAttack(OCMTest(weapon, armour, 3, weaponType.name, Runnable {
|
||||
preparations.run()
|
||||
defender.maximumNoDamageTicks = 30
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
private fun queueAttack(test: OCMTest) {
|
||||
testQueue.add(test)
|
||||
}
|
||||
|
||||
private fun calculateAttackDamage(weapon: ItemStack): Double {
|
||||
val weaponType = weapon.type
|
||||
// Attack components order: (Base + Potion effects, scaled by attack delay) + Critical Hit + (Enchantments, scaled by attack delay)
|
||||
// Hurt components order: Overdamage - Armour Effects
|
||||
var expectedDamage = getDamage(weaponType)
|
||||
|
||||
// Weakness effect, 1.8: -0.5
|
||||
// We ignore the level as there is only one level of weakness potion
|
||||
val weaknessAddend = if (attacker.hasPotionEffect(XPotion.WEAKNESS.get()!!)) -0.5 else 0.0
|
||||
|
||||
// Strength effect
|
||||
// 1.8: +130% for each strength level
|
||||
val strength = attacker.getPotionEffectCompat(XPotion.STRENGTH.get()!!)
|
||||
if (strength != null) expectedDamage += (strength.amplifier + 1) * 1.3 * expectedDamage
|
||||
|
||||
expectedDamage += weaknessAddend
|
||||
|
||||
// Take into account damage reduction because of cooldown
|
||||
val attackCooldown = defender.attackCooldown
|
||||
expectedDamage *= (0.2f + attackCooldown * attackCooldown * 0.8f).toDouble()
|
||||
|
||||
// Critical hit
|
||||
if (isCriticalHit1_8(attacker)) {
|
||||
expectedDamage *= 1.5
|
||||
}
|
||||
|
||||
// Weapon Enchantments
|
||||
var sharpnessDamage = getOldSharpnessDamage(weapon.getEnchantmentLevel(XEnchantment.SHARPNESS.get()!!))
|
||||
sharpnessDamage *= attackCooldown.toDouble() // Scale by attack cooldown strength
|
||||
expectedDamage += sharpnessDamage
|
||||
|
||||
return expectedDamage
|
||||
}
|
||||
|
||||
private fun wasFakeOverdamage(weapon: ItemStack): Boolean {
|
||||
val weaponDamage = calculateAttackDamage(weapon)
|
||||
val lastDamage = defender.lastDamage
|
||||
return defender.noDamageTicks.toFloat() > defender.maximumNoDamageTicks.toFloat() / 2.0f &&
|
||||
weaponDamage <= lastDamage
|
||||
}
|
||||
|
||||
private fun wasOverdamaged(rawWeaponDamage: Double): Boolean {
|
||||
val lastDamage = defender.lastDamage
|
||||
return defender.noDamageTicks.toFloat() > defender.maximumNoDamageTicks.toFloat() / 2.0f &&
|
||||
rawWeaponDamage > lastDamage
|
||||
}
|
||||
|
||||
private fun calculateExpectedDamage(weapon: ItemStack, armourContents: Array<ItemStack>): Float {
|
||||
var expectedDamage = calculateAttackDamage(weapon)
|
||||
|
||||
if (wasOverdamaged(expectedDamage)) {
|
||||
val lastDamage = defender.lastDamage
|
||||
plugin.logger.info("Overdamaged: " + expectedDamage + " - " + lastDamage + " = " + (expectedDamage - lastDamage))
|
||||
expectedDamage -= lastDamage
|
||||
}
|
||||
|
||||
if (defender.isBlocking) {
|
||||
plugin.logger.info("DEFENDER IS BLOCKING $expectedDamage")
|
||||
expectedDamage -= max(0.0, (expectedDamage - 1)) * 0.5
|
||||
plugin.logger.info("AFTER BLOCK $expectedDamage")
|
||||
}
|
||||
|
||||
expectedDamage = getDamageAfterArmour1_8(
|
||||
defender,
|
||||
expectedDamage,
|
||||
armourContents,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
false
|
||||
)
|
||||
|
||||
return expectedDamage.toFloat()
|
||||
}
|
||||
|
||||
private fun runQueuedTests() {
|
||||
plugin.logger.info("Running " + testQueue.size + " tests")
|
||||
|
||||
val listener: Listener = object : Listener {
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
fun onEvent(e: EntityDamageByEntityEvent) {
|
||||
val damager = e.damager
|
||||
if (damager.uniqueId !== attacker.uniqueId ||
|
||||
e.entity.uniqueId !== defender.uniqueId
|
||||
) return
|
||||
|
||||
val weapon = (damager as Player).inventory.itemInMainHand
|
||||
val weaponType = weapon.type
|
||||
var test = testQueue.remove()
|
||||
var expectedWeapon = test.weapon
|
||||
var expectedDamage = calculateExpectedDamage(expectedWeapon, test.armour)
|
||||
|
||||
while (weaponType != expectedWeapon.type) {
|
||||
expectedDamage = calculateExpectedDamage(expectedWeapon, test.armour)
|
||||
plugin.logger.info("SKIPPED " + expectedWeapon.type + " Expected Damage: " + expectedDamage)
|
||||
if (expectedDamage == 0f) tally!!.passed()
|
||||
else tally!!.failed()
|
||||
test = testQueue.remove()
|
||||
expectedWeapon = test.weapon
|
||||
}
|
||||
|
||||
if (wasFakeOverdamage(weapon) && e.isCancelled) {
|
||||
plugin.logger.info("PASSED Fake overdamage " + expectedDamage + " < " + (e.entity as LivingEntity).lastDamage)
|
||||
tally!!.passed()
|
||||
} else {
|
||||
val weaponMessage = "E: " + expectedWeapon.type.name + " A: " + weaponType.name
|
||||
assertEquals(
|
||||
expectedDamage, e.finalDamage.toFloat(),
|
||||
tally!!, weaponMessage, sender!!
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Bukkit.getServer().pluginManager.registerEvents(listener, plugin)
|
||||
|
||||
val testCount = testQueue.size.toLong()
|
||||
|
||||
var attackDelay: Long = 0
|
||||
|
||||
for (test in testQueue) {
|
||||
attackDelay += test.attackDelay
|
||||
|
||||
Bukkit.getScheduler().runTaskLater(plugin, Runnable {
|
||||
beforeEach()
|
||||
test.preparations.run()
|
||||
preparePlayer(test.weapon)
|
||||
attackCompat(attacker, defender)
|
||||
afterEach()
|
||||
}, attackDelay)
|
||||
}
|
||||
|
||||
Bukkit.getScheduler().runTaskLater(plugin, Runnable {
|
||||
afterAll(testCount)
|
||||
EntityDamageByEntityEvent.getHandlerList().unregister(listener)
|
||||
}, attackDelay + 1)
|
||||
}
|
||||
|
||||
private fun beforeAll() {
|
||||
plugin.logger.info("Running before all")
|
||||
for (player in listOfNotNull(attacker, defender)) {
|
||||
player.gameMode = GameMode.SURVIVAL
|
||||
player.maximumNoDamageTicks = 20
|
||||
player.noDamageTicks = 0 // remove spawn invulnerability
|
||||
player.isInvulnerable = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun afterAll(testCount: Long) {
|
||||
fakeAttacker.removePlayer()
|
||||
fakeDefender.removePlayer()
|
||||
|
||||
val missed = testCount - tally!!.total
|
||||
val message = String.format(
|
||||
"Passed: %d Failed: %d Total: %d Missed: %d",
|
||||
tally!!.passed,
|
||||
tally!!.failed,
|
||||
tally!!.total,
|
||||
missed
|
||||
)
|
||||
plugin.logger.info(message)
|
||||
}
|
||||
|
||||
private fun beforeEach() {
|
||||
for (player in listOfNotNull(attacker, defender)) {
|
||||
player.inventory.clear()
|
||||
player.exhaustion = 0f
|
||||
player.health = 20.0
|
||||
}
|
||||
}
|
||||
|
||||
private fun preparePlayer(weapon: ItemStack) {
|
||||
if (weapon.hasItemMeta()) {
|
||||
val meta = weapon.itemMeta
|
||||
val speedModifier = createAttributeModifier(
|
||||
name = "speed",
|
||||
amount = 1000.0,
|
||||
operation = AttributeModifier.Operation.ADD_NUMBER,
|
||||
slot = EquipmentSlot.HAND
|
||||
)
|
||||
val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get()
|
||||
if (attackSpeedAttribute != null) {
|
||||
addAttributeModifierCompat(meta!!, attackSpeedAttribute, speedModifier)
|
||||
}
|
||||
weapon.setItemMeta(meta)
|
||||
}
|
||||
attacker.inventory.setItemInMainHand(weapon)
|
||||
attacker.updateInventory()
|
||||
|
||||
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()
|
||||
val armourAttribute = XAttribute.ARMOR.get()
|
||||
val ai = attackDamageAttribute?.let { attacker.getAttribute(it) }
|
||||
val defenderArmour = armourAttribute?.let { defender.getAttribute(it) }
|
||||
|
||||
if (attackDamageAttribute != null && ai != null) {
|
||||
getDefaultAttributeModifiersCompat(weapon, EquipmentSlot.HAND, attackDamageAttribute).forEach(
|
||||
Consumer { am: AttributeModifier? ->
|
||||
ai.removeModifier(am!!)
|
||||
ai.addModifier(am)
|
||||
})
|
||||
}
|
||||
|
||||
val armourContents = defender.inventory.armorContents
|
||||
plugin.logger.info(
|
||||
"Armour: " + Arrays.stream(armourContents).filter { obj: ItemStack? -> Objects.nonNull(obj) }
|
||||
.map { `is`: ItemStack -> `is`.type.name }
|
||||
.reduce { a: String, b: String -> "$a, $b" }
|
||||
.orElse("none")
|
||||
)
|
||||
for (i in armourContents.indices) {
|
||||
val itemStack = armourContents[i] ?: continue
|
||||
val type = itemStack.type
|
||||
val slot =
|
||||
arrayOf(
|
||||
EquipmentSlot.FEET,
|
||||
EquipmentSlot.LEGS,
|
||||
EquipmentSlot.CHEST,
|
||||
EquipmentSlot.HEAD
|
||||
)[i]
|
||||
if (armourAttribute != null && defenderArmour != null) {
|
||||
for (attributeModifier in getDefaultAttributeModifiersCompat(itemStack, slot, armourAttribute)) {
|
||||
defenderArmour.removeModifier(attributeModifier)
|
||||
defenderArmour.addModifier(attributeModifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun afterEach() {
|
||||
for (player in listOfNotNull(attacker, defender)) {
|
||||
player.exhaustion = 0f
|
||||
player.health = 20.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.StringSpec
|
||||
import io.kotest.matchers.doubles.shouldBeExactly
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
|
||||
import kotlinx.coroutines.delay
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.entity.EntityType
|
||||
import org.bukkit.entity.LivingEntity
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.EventPriority
|
||||
import org.bukkit.event.Listener
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent
|
||||
import org.bukkit.event.entity.EntityDamageEvent
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class InGameTesterIntegrationTest :
|
||||
StringSpec({
|
||||
val plugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
extension(MainThreadDispatcherExtension(plugin))
|
||||
lateinit var attacker: Player
|
||||
lateinit var defender: Player
|
||||
lateinit var fakeAttacker: FakePlayer
|
||||
lateinit var fakeDefender: FakePlayer
|
||||
|
||||
fun <T> runSync(action: () -> T): T =
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit.getScheduler().callSyncMethod(plugin, Callable { action() }).get()
|
||||
}
|
||||
|
||||
fun preparePlayers() {
|
||||
println("Preparing players")
|
||||
val world = Bukkit.getServer().getWorld("world")
|
||||
// TODO might need to specify server superflat?
|
||||
val location = Location(world, 0.0, 100.0, 0.0)
|
||||
|
||||
fakeAttacker = FakePlayer(plugin)
|
||||
fakeAttacker.spawn(location.add(2.0, 0.0, 0.0))
|
||||
fakeDefender = FakePlayer(plugin)
|
||||
val defenderLocation = location.add(0.0, 0.0, 2.0)
|
||||
fakeDefender.spawn(defenderLocation)
|
||||
|
||||
attacker = checkNotNull(Bukkit.getPlayer(fakeAttacker.uuid))
|
||||
defender = checkNotNull(Bukkit.getPlayer(fakeDefender.uuid))
|
||||
|
||||
// Turn defender to face attacker
|
||||
defenderLocation.yaw = 180f
|
||||
defenderLocation.pitch = 0f
|
||||
defender.teleport(defenderLocation)
|
||||
|
||||
// modeset of attacker takes precedence
|
||||
var playerData = getPlayerData(attacker.uniqueId)
|
||||
playerData.setModesetForWorld(attacker.world.uid, "old")
|
||||
setPlayerData(attacker.uniqueId, playerData)
|
||||
|
||||
playerData = getPlayerData(defender.uniqueId)
|
||||
playerData.setModesetForWorld(defender.world.uid, "new")
|
||||
setPlayerData(defender.uniqueId, playerData)
|
||||
}
|
||||
|
||||
beforeSpec {
|
||||
plugin.logger.info("Running before all")
|
||||
runSync { preparePlayers() }
|
||||
}
|
||||
|
||||
beforeTest {
|
||||
runSync {
|
||||
for (player in listOfNotNull(attacker, defender)) {
|
||||
player.gameMode = GameMode.SURVIVAL
|
||||
player.maximumNoDamageTicks = 20
|
||||
player.noDamageTicks = 0 // remove spawn invulnerability
|
||||
player.isInvulnerable = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterSpec {
|
||||
plugin.logger.info("Running after all")
|
||||
runSync {
|
||||
fakeAttacker.removePlayer()
|
||||
fakeDefender.removePlayer()
|
||||
}
|
||||
}
|
||||
|
||||
"test melee attacks" {
|
||||
println("Testing melee attack")
|
||||
val netheriteSword = runCatching { Material.valueOf("NETHERITE_SWORD") }.getOrNull()
|
||||
val weapon = ItemStack(netheriteSword ?: Material.STONE_SWORD)
|
||||
val victim =
|
||||
runSync {
|
||||
attacker.world.spawnEntity(attacker.location.clone().add(1.5, 0.0, 0.0), EntityType.ZOMBIE) as LivingEntity
|
||||
}
|
||||
try {
|
||||
runSync {
|
||||
attacker.inventory.setItemInMainHand(weapon)
|
||||
attacker.updateInventory()
|
||||
victim.maximumNoDamageTicks = 0
|
||||
victim.noDamageTicks = 0
|
||||
}
|
||||
|
||||
var damageEvents = 0
|
||||
var sawExpectedWeaponAtDamage = false
|
||||
var sawMeleeCause = false
|
||||
var sawPositiveUncancelledDamage = false
|
||||
val listener =
|
||||
object : Listener {
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
fun onEntityDamageByEntity(event: EntityDamageByEntityEvent) {
|
||||
if (event.damager.uniqueId != attacker.uniqueId || event.entity.uniqueId != victim.uniqueId) {
|
||||
return
|
||||
}
|
||||
damageEvents += 1
|
||||
sawExpectedWeaponAtDamage = (attacker.inventory.itemInMainHand.type == weapon.type)
|
||||
sawMeleeCause =
|
||||
event.cause == EntityDamageEvent.DamageCause.ENTITY_ATTACK ||
|
||||
event.cause == EntityDamageEvent.DamageCause.ENTITY_SWEEP_ATTACK
|
||||
if (!event.isCancelled && event.finalDamage > 0.0) {
|
||||
sawPositiveUncancelledDamage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
runSync { Bukkit.getPluginManager().registerEvents(listener, plugin) }
|
||||
|
||||
val victimStartHealth = runSync { victim.health }
|
||||
var minimumVictimHealth = victimStartHealth
|
||||
try {
|
||||
repeat(12) {
|
||||
val damaged =
|
||||
runSync {
|
||||
attackCompat(attacker, victim)
|
||||
minimumVictimHealth = minOf(minimumVictimHealth, victim.health)
|
||||
minimumVictimHealth < victimStartHealth && sawPositiveUncancelledDamage
|
||||
}
|
||||
if (damaged) {
|
||||
return@repeat
|
||||
}
|
||||
delay(2 * 50L)
|
||||
}
|
||||
} finally {
|
||||
runSync { EntityDamageByEntityEvent.getHandlerList().unregister(listener) }
|
||||
}
|
||||
|
||||
repeat(4) {
|
||||
runSync {
|
||||
minimumVictimHealth = minOf(minimumVictimHealth, victim.health)
|
||||
}
|
||||
if (minimumVictimHealth < victimStartHealth) {
|
||||
return@repeat
|
||||
}
|
||||
delay(50L)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION") // Deprecated API kept for older server compatibility in tests.
|
||||
runSync { attacker.health } shouldBeExactly runSync { attacker.maxHealth }
|
||||
sawPositiveUncancelledDamage shouldBe true
|
||||
(minimumVictimHealth < victimStartHealth) shouldBe true
|
||||
(damageEvents > 0) shouldBe true
|
||||
sawExpectedWeaponAtDamage shouldBe true
|
||||
sawMeleeCause shouldBe true
|
||||
} finally {
|
||||
runSync { victim.remove() }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,754 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import com.cryptomorin.xseries.XAttribute
|
||||
import com.cryptomorin.xseries.XMaterial
|
||||
import com.cryptomorin.xseries.XPotion
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.doubles.shouldBeGreaterThanOrEqual
|
||||
import io.kotest.matchers.ints.shouldBeExactly
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.Config
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.NamespacedKey
|
||||
import org.bukkit.attribute.AttributeModifier
|
||||
import org.bukkit.entity.LivingEntity
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.HandlerList
|
||||
import org.bukkit.event.Listener
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent
|
||||
import org.bukkit.event.entity.EntityDamageEvent
|
||||
import org.bukkit.event.player.PlayerItemConsumeEvent
|
||||
import org.bukkit.inventory.EquipmentSlot
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.inventory.meta.PotionMeta
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import org.bukkit.potion.PotionData
|
||||
import org.bukkit.potion.PotionEffect
|
||||
import org.bukkit.potion.PotionEffectType
|
||||
import org.bukkit.potion.PotionType
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Callable
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
|
||||
import kotlin.math.abs
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class InvulnerabilityDamageIntegrationTest : FunSpec({
|
||||
val plugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
extensions(MainThreadDispatcherExtension(plugin))
|
||||
|
||||
fun runSync(action: () -> Unit) {
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit.getScheduler().callSyncMethod(plugin, Callable {
|
||||
action()
|
||||
null
|
||||
}).get()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delayTicks(ticks: Long) {
|
||||
delay(ticks * 50L)
|
||||
}
|
||||
|
||||
fun prepareWeapon(item: ItemStack) {
|
||||
val meta = item.itemMeta ?: return
|
||||
val speedModifier = createAttributeModifier(
|
||||
name = "speed",
|
||||
amount = 1000.0,
|
||||
operation = AttributeModifier.Operation.ADD_NUMBER,
|
||||
slot = EquipmentSlot.HAND
|
||||
)
|
||||
val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return
|
||||
addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)
|
||||
item.itemMeta = meta
|
||||
}
|
||||
|
||||
fun applyAttackDamageModifiers(player: Player, item: ItemStack) {
|
||||
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return
|
||||
val attackAttribute = player.getAttribute(attackDamageAttribute) ?: return
|
||||
val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)
|
||||
val expectedAmounts = modifiers
|
||||
.filter { it.operation == AttributeModifier.Operation.ADD_NUMBER }
|
||||
.map { it.amount }
|
||||
val knownWeaponAmounts = NewWeaponDamage.values()
|
||||
.map { it.damage.toDouble() - 1.0 }
|
||||
.filter { it > 0.0 }
|
||||
.toSet()
|
||||
|
||||
fun matchesAmount(first: Double, second: Double): Boolean = abs(first - second) <= 0.0001
|
||||
|
||||
val existingModifiers = attackAttribute.modifiers.toList()
|
||||
existingModifiers
|
||||
.filter { it.operation == AttributeModifier.Operation.ADD_NUMBER && it.amount > 0.0 }
|
||||
.filter { modifier ->
|
||||
knownWeaponAmounts.any { matchesAmount(it, modifier.amount) } &&
|
||||
expectedAmounts.none { expected -> matchesAmount(expected, modifier.amount) }
|
||||
}
|
||||
.forEach { attackAttribute.removeModifier(it) }
|
||||
|
||||
modifiers.forEach { modifier ->
|
||||
val alreadyApplied = attackAttribute.modifiers.any {
|
||||
it.operation == modifier.operation && matchesAmount(it.amount, modifier.amount)
|
||||
}
|
||||
if (!alreadyApplied) {
|
||||
attackAttribute.addModifier(modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun equip(player: Player, item: ItemStack) {
|
||||
prepareWeapon(item)
|
||||
player.inventory.setItemInMainHand(item)
|
||||
applyAttackDamageModifiers(player, item)
|
||||
player.updateInventory()
|
||||
}
|
||||
|
||||
fun spawnAttacker(location: Location): Pair<FakePlayer, Player> {
|
||||
val fake = FakePlayer(plugin)
|
||||
fake.spawn(location)
|
||||
val player = checkNotNull(Bukkit.getPlayer(fake.uuid))
|
||||
player.gameMode = GameMode.SURVIVAL
|
||||
player.isInvulnerable = false
|
||||
player.inventory.clear()
|
||||
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
|
||||
val playerData = getPlayerData(player.uniqueId)
|
||||
playerData.setModesetForWorld(player.world.uid, "old")
|
||||
setPlayerData(player.uniqueId, playerData)
|
||||
return fake to player
|
||||
}
|
||||
|
||||
fun spawnVictim(location: Location): LivingEntity {
|
||||
val world = location.world ?: error("World missing for victim spawn")
|
||||
return world.spawn(location, org.bukkit.entity.Cow::class.java).apply {
|
||||
maximumNoDamageTicks = 20
|
||||
noDamageTicks = 0
|
||||
isInvulnerable = false
|
||||
health = maxHealth
|
||||
}
|
||||
}
|
||||
|
||||
fun createWeaknessPotion(): ItemStack {
|
||||
val item = ItemStack(Material.POTION)
|
||||
val meta = item.itemMeta as PotionMeta
|
||||
try {
|
||||
meta.basePotionType = PotionType.WEAKNESS
|
||||
} catch (e: NoSuchMethodError) {
|
||||
@Suppress("DEPRECATION") // Required for legacy server compatibility.
|
||||
meta.basePotionData = PotionData(PotionType.WEAKNESS, false, false)
|
||||
}
|
||||
item.itemMeta = meta
|
||||
return item
|
||||
}
|
||||
|
||||
fun consumeWeaknessPotion(player: Player): PotionEffect {
|
||||
val item = createWeaknessPotion()
|
||||
val ctor = PlayerItemConsumeEvent::class.java.constructors.firstOrNull { constructor ->
|
||||
val params = constructor.parameterTypes
|
||||
params.size == 3 &&
|
||||
Player::class.java.isAssignableFrom(params[0]) &&
|
||||
ItemStack::class.java.isAssignableFrom(params[1]) &&
|
||||
EquipmentSlot::class.java.isAssignableFrom(params[2])
|
||||
}
|
||||
val event = if (ctor != null) {
|
||||
ctor.newInstance(player, item, EquipmentSlot.HAND) as PlayerItemConsumeEvent
|
||||
} else {
|
||||
PlayerItemConsumeEvent(player, item)
|
||||
}
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
val meta = event.item.itemMeta as PotionMeta
|
||||
return meta.customEffects.firstOrNull { it.type == PotionEffectType.WEAKNESS }
|
||||
?: error("Weakness effect missing from potion meta")
|
||||
}
|
||||
|
||||
fun supportsDamageTypeApi(): Boolean {
|
||||
return try {
|
||||
Class.forName("org.bukkit.damage.DamageType")
|
||||
Class.forName("org.bukkit.damage.DamageSource")
|
||||
Class.forName("org.bukkit.Registry").getField("DAMAGE_TYPE")
|
||||
EntityDamageEvent::class.java.getMethod("getDamageSource")
|
||||
true
|
||||
} catch (_: ReflectiveOperationException) {
|
||||
false
|
||||
} catch (_: LinkageError) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun damageTypeClass(): Class<*> = Class.forName("org.bukkit.damage.DamageType")
|
||||
|
||||
fun resolveDamageType(key: NamespacedKey): Any {
|
||||
val registry = Class.forName("org.bukkit.Registry").getField("DAMAGE_TYPE").get(null)
|
||||
return registry.javaClass.getMethod("get", NamespacedKey::class.java).invoke(registry, key)
|
||||
?: error("Damage type $key was not registered")
|
||||
}
|
||||
|
||||
fun bypassesCooldownTagContains(damageType: Any): Boolean {
|
||||
val getTag = Bukkit::class.java.getMethod(
|
||||
"getTag",
|
||||
String::class.java,
|
||||
NamespacedKey::class.java,
|
||||
Class::class.java
|
||||
)
|
||||
val tag = listOf("damage_type", "damage_types").firstNotNullOfOrNull { registry ->
|
||||
try {
|
||||
getTag.invoke(null, registry, NamespacedKey.minecraft("bypasses_cooldown"), damageTypeClass())
|
||||
} catch (_: ReflectiveOperationException) {
|
||||
null
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
} ?: error("minecraft:bypasses_cooldown damage-type tag was not available")
|
||||
val values = (tag as org.bukkit.Tag<*>).values
|
||||
return values.contains(damageType)
|
||||
}
|
||||
|
||||
fun buildDamageSource(damageType: Any): Any {
|
||||
val sourceClass = Class.forName("org.bukkit.damage.DamageSource")
|
||||
val builder = sourceClass.getMethod("builder", damageTypeClass()).invoke(null, damageType)
|
||||
return builder.javaClass.getMethod("build").invoke(builder)
|
||||
}
|
||||
|
||||
fun createDamageEvent(victim: LivingEntity, damageSource: Any, damage: Double): EntityDamageEvent {
|
||||
return EntityDamageEvent::class.java.getConstructor(
|
||||
org.bukkit.entity.Entity::class.java,
|
||||
EntityDamageEvent.DamageCause::class.java,
|
||||
Class.forName("org.bukkit.damage.DamageSource"),
|
||||
java.lang.Double.TYPE
|
||||
).newInstance(
|
||||
victim,
|
||||
EntityDamageEvent.DamageCause.CUSTOM,
|
||||
damageSource,
|
||||
damage
|
||||
) as EntityDamageEvent
|
||||
}
|
||||
|
||||
fun configuredDamageListener(): Pair<Listener, java.lang.reflect.Field> {
|
||||
val listener = HandlerList.getRegisteredListeners(ocm)
|
||||
.map { it.listener }
|
||||
.firstOrNull { it.javaClass.name.endsWith("EntityDamageByEntityListener") }
|
||||
?: error("EntityDamageByEntityListener not registered")
|
||||
val field = listener.javaClass.getDeclaredField("lastDamages").apply { isAccessible = true }
|
||||
return listener to field
|
||||
}
|
||||
|
||||
test("second hit in the same tick should still fire inside invulnerability").config(
|
||||
enabled = false
|
||||
) {
|
||||
// Disabled: current vanilla pipeline drops same-tick follow-up hits during invulnerability.
|
||||
// We will revisit once the intended behaviour is defined across versions.
|
||||
val events = mutableListOf<EntityDamageByEntityEvent>()
|
||||
lateinit var attacker1: Player
|
||||
lateinit var attacker2: Player
|
||||
var victim: LivingEntity? = null
|
||||
var fake1: FakePlayer? = null
|
||||
var fake2: FakePlayer? = null
|
||||
|
||||
val listener = object : Listener {
|
||||
@EventHandler
|
||||
fun onDamage(event: EntityDamageByEntityEvent) {
|
||||
val currentVictim = victim ?: return
|
||||
if (event.entity.uniqueId == currentVictim.uniqueId &&
|
||||
(event.damager.uniqueId == attacker1.uniqueId || event.damager.uniqueId == attacker2.uniqueId)
|
||||
) {
|
||||
events.add(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val attacker1Location = Location(world, 0.0, 100.0, 0.0)
|
||||
val attacker2Location = Location(world, 0.0, 100.0, 2.0)
|
||||
val victimLocation = Location(world, 1.2, 100.0, 0.0)
|
||||
|
||||
val (fakeA, playerA) = spawnAttacker(attacker1Location)
|
||||
val (fakeB, playerB) = spawnAttacker(attacker2Location)
|
||||
fake1 = fakeA
|
||||
fake2 = fakeB
|
||||
attacker1 = playerA
|
||||
attacker2 = playerB
|
||||
val spawnedVictim = spawnVictim(victimLocation)
|
||||
victim = spawnedVictim
|
||||
|
||||
Bukkit.getPluginManager().registerEvents(listener, plugin)
|
||||
|
||||
equip(attacker1, ItemStack(Material.DIAMOND_SWORD))
|
||||
equip(attacker2, ItemStack(Material.STONE_SWORD))
|
||||
|
||||
attackCompat(attacker1, spawnedVictim)
|
||||
attackCompat(attacker2, spawnedVictim)
|
||||
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()
|
||||
val attacker1Damage = attackDamageAttribute?.let { attacker1.getAttribute(it)?.value }
|
||||
val attacker2Damage = attackDamageAttribute?.let { attacker2.getAttribute(it)?.value }
|
||||
plugin.logger.info(
|
||||
"Invuln same-tick debug: events=${events.size} " +
|
||||
"noDamageTicks=${spawnedVictim.noDamageTicks} lastDamage=${spawnedVictim.lastDamage} " +
|
||||
"attacker1Damage=$attacker1Damage attacker2Damage=$attacker2Damage"
|
||||
)
|
||||
}
|
||||
delayTicks(3)
|
||||
runSync {
|
||||
val currentVictim = checkNotNull(victim)
|
||||
plugin.logger.info(
|
||||
"Invuln same-tick debug (post): events=${events.size} " +
|
||||
"noDamageTicks=${currentVictim.noDamageTicks} lastDamage=${currentVictim.lastDamage} " +
|
||||
"lastEventDamage=${events.lastOrNull()?.damage} lastEventFinal=${events.lastOrNull()?.finalDamage}"
|
||||
)
|
||||
}
|
||||
events.size.shouldBeExactly(2)
|
||||
} finally {
|
||||
HandlerList.unregisterAll(listener)
|
||||
runSync {
|
||||
fake1?.removePlayer()
|
||||
fake2?.removePlayer()
|
||||
victim?.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("custom bypasses_cooldown damage type skips OCM invulnerability overdamage") {
|
||||
if (!supportsDamageTypeApi()) {
|
||||
plugin.logger.info("Skipping bypasses_cooldown damage-type test: Bukkit damage-source API is unavailable")
|
||||
return@test
|
||||
}
|
||||
|
||||
val tickDamageType = resolveDamageType(NamespacedKey("ocmtest", "tick_damage"))
|
||||
bypassesCooldownTagContains(tickDamageType).shouldBe(true)
|
||||
|
||||
val genericDamageType = resolveDamageType(NamespacedKey.minecraft("generic"))
|
||||
bypassesCooldownTagContains(genericDamageType).shouldBe(false)
|
||||
|
||||
var taggedVictim: LivingEntity? = null
|
||||
var controlVictim: LivingEntity? = null
|
||||
|
||||
try {
|
||||
runSync {
|
||||
val (edbelListener, lastDamagesField) = configuredDamageListener()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val lastDamages = lastDamagesField.get(edbelListener) as MutableMap<UUID, Double>
|
||||
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
taggedVictim = spawnVictim(Location(world, 1.2, 100.0, 0.0)).apply {
|
||||
noDamageTicks = maximumNoDamageTicks
|
||||
lastDamage = 4.0
|
||||
}
|
||||
controlVictim = spawnVictim(Location(world, 3.2, 100.0, 0.0)).apply {
|
||||
noDamageTicks = maximumNoDamageTicks
|
||||
lastDamage = 4.0
|
||||
}
|
||||
|
||||
val currentTaggedVictim = checkNotNull(taggedVictim)
|
||||
val currentControlVictim = checkNotNull(controlVictim)
|
||||
lastDamages[currentTaggedVictim.uniqueId] = 4.0
|
||||
lastDamages[currentControlVictim.uniqueId] = 4.0
|
||||
|
||||
val taggedEvent = createDamageEvent(currentTaggedVictim, buildDamageSource(tickDamageType), 4.0)
|
||||
Bukkit.getPluginManager().callEvent(taggedEvent)
|
||||
taggedEvent.isCancelled.shouldBe(false)
|
||||
taggedEvent.damage.shouldBe(4.0)
|
||||
|
||||
val controlEvent = createDamageEvent(currentControlVictim, buildDamageSource(genericDamageType), 4.0)
|
||||
Bukkit.getPluginManager().callEvent(controlEvent)
|
||||
controlEvent.isCancelled.shouldBe(true)
|
||||
controlEvent.damage.shouldBe(0.0)
|
||||
}
|
||||
} finally {
|
||||
runSync {
|
||||
taggedVictim?.remove()
|
||||
controlVictim?.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("slightly higher base damage inside invulnerability should fire")
|
||||
.config(enabled = false) {
|
||||
// Ignored for now: behaviour differs across versions and is not addressed yet.
|
||||
val events = mutableListOf<EntityDamageByEntityEvent>()
|
||||
lateinit var attacker: Player
|
||||
var victim: LivingEntity? = null
|
||||
var fake: FakePlayer? = null
|
||||
|
||||
val listener = object : Listener {
|
||||
@EventHandler
|
||||
fun onDamage(event: EntityDamageByEntityEvent) {
|
||||
val currentVictim = victim ?: return
|
||||
if (event.entity.uniqueId == currentVictim.uniqueId &&
|
||||
event.damager.uniqueId == attacker.uniqueId
|
||||
) {
|
||||
events.add(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
|
||||
val victimLocation = Location(world, 1.2, 100.0, 0.0)
|
||||
|
||||
val (fakeA, playerA) = spawnAttacker(attackerLocation)
|
||||
fake = fakeA
|
||||
attacker = playerA
|
||||
val spawnedVictim = spawnVictim(victimLocation)
|
||||
victim = spawnedVictim
|
||||
|
||||
Bukkit.getPluginManager().registerEvents(listener, plugin)
|
||||
|
||||
val woodenSword = XMaterial.WOODEN_SWORD.parseItem()
|
||||
?: error("WOODEN_SWORD material not available")
|
||||
equip(attacker, woodenSword)
|
||||
attackCompat(attacker, spawnedVictim)
|
||||
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()
|
||||
val attackerDamage = attackDamageAttribute?.let { attacker.getAttribute(it)?.value }
|
||||
plugin.logger.info(
|
||||
"Invuln overdamage debug (first hit): events=${events.size} " +
|
||||
"noDamageTicks=${spawnedVictim.noDamageTicks} lastDamage=${spawnedVictim.lastDamage} " +
|
||||
"attackerDamage=$attackerDamage"
|
||||
)
|
||||
}
|
||||
|
||||
delayTicks(2)
|
||||
|
||||
runSync {
|
||||
val currentVictim = checkNotNull(victim)
|
||||
val firstDamage = events.firstOrNull()?.damage
|
||||
?: error("Expected a damage event from the first hit")
|
||||
currentVictim.noDamageTicks = currentVictim.maximumNoDamageTicks
|
||||
currentVictim.lastDamage = firstDamage
|
||||
|
||||
val stoneSword = XMaterial.STONE_SWORD.parseItem()
|
||||
?: error("STONE_SWORD material not available")
|
||||
equip(attacker, stoneSword)
|
||||
attackCompat(attacker, currentVictim)
|
||||
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()
|
||||
val attackerDamage = attackDamageAttribute?.let { attacker.getAttribute(it)?.value }
|
||||
plugin.logger.info(
|
||||
"Invuln overdamage debug (second hit): events=${events.size} " +
|
||||
"noDamageTicks=${currentVictim.noDamageTicks} lastDamage=${currentVictim.lastDamage} " +
|
||||
"attackerDamage=$attackerDamage firstDamage=$firstDamage"
|
||||
)
|
||||
}
|
||||
|
||||
delayTicks(2)
|
||||
runSync {
|
||||
val currentVictim = checkNotNull(victim)
|
||||
plugin.logger.info(
|
||||
"Invuln overdamage debug (post): events=${events.size} " +
|
||||
"noDamageTicks=${currentVictim.noDamageTicks} lastDamage=${currentVictim.lastDamage} " +
|
||||
"lastEventDamage=${events.lastOrNull()?.damage} lastEventFinal=${events.lastOrNull()?.finalDamage}"
|
||||
)
|
||||
}
|
||||
events.size.shouldBeExactly(2)
|
||||
} finally {
|
||||
HandlerList.unregisterAll(listener)
|
||||
runSync {
|
||||
fake?.removePlayer()
|
||||
victim?.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("clearly higher base damage inside invulnerability should fire") {
|
||||
val events = mutableListOf<EntityDamageByEntityEvent>()
|
||||
lateinit var attacker: Player
|
||||
var victim: LivingEntity? = null
|
||||
var fake: FakePlayer? = null
|
||||
|
||||
val listener = object : Listener {
|
||||
@EventHandler
|
||||
fun onDamage(event: EntityDamageByEntityEvent) {
|
||||
val currentVictim = victim ?: return
|
||||
if (event.entity.uniqueId == currentVictim.uniqueId &&
|
||||
event.damager.uniqueId == attacker.uniqueId
|
||||
) {
|
||||
events.add(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
|
||||
val victimLocation = Location(world, 1.2, 100.0, 0.0)
|
||||
|
||||
val (fakeA, playerA) = spawnAttacker(attackerLocation)
|
||||
fake = fakeA
|
||||
attacker = playerA
|
||||
val spawnedVictim = spawnVictim(victimLocation)
|
||||
victim = spawnedVictim
|
||||
|
||||
Bukkit.getPluginManager().registerEvents(listener, plugin)
|
||||
|
||||
val woodenSword = XMaterial.WOODEN_SWORD.parseItem()
|
||||
?: error("WOODEN_SWORD material not available")
|
||||
equip(attacker, woodenSword)
|
||||
attackCompat(attacker, spawnedVictim)
|
||||
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()
|
||||
val attackerDamage = attackDamageAttribute?.let { attacker.getAttribute(it)?.value }
|
||||
plugin.logger.info(
|
||||
"Invuln overdamage (iron) debug (first hit): events=${events.size} " +
|
||||
"noDamageTicks=${spawnedVictim.noDamageTicks} lastDamage=${spawnedVictim.lastDamage} " +
|
||||
"attackerDamage=$attackerDamage"
|
||||
)
|
||||
}
|
||||
|
||||
delayTicks(2)
|
||||
|
||||
runSync {
|
||||
val currentVictim = checkNotNull(victim)
|
||||
val firstDamage = events.firstOrNull()?.damage
|
||||
?: error("Expected a damage event from the first hit")
|
||||
currentVictim.noDamageTicks = currentVictim.maximumNoDamageTicks
|
||||
currentVictim.lastDamage = firstDamage
|
||||
|
||||
val ironSword = XMaterial.IRON_SWORD.parseItem()
|
||||
?: error("IRON_SWORD material not available")
|
||||
equip(attacker, ironSword)
|
||||
attackCompat(attacker, currentVictim)
|
||||
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()
|
||||
val attackerDamage = attackDamageAttribute?.let { attacker.getAttribute(it)?.value }
|
||||
plugin.logger.info(
|
||||
"Invuln overdamage (iron) debug (second hit): events=${events.size} " +
|
||||
"noDamageTicks=${currentVictim.noDamageTicks} lastDamage=${currentVictim.lastDamage} " +
|
||||
"attackerDamage=$attackerDamage firstDamage=$firstDamage"
|
||||
)
|
||||
}
|
||||
|
||||
delayTicks(2)
|
||||
runSync {
|
||||
val currentVictim = checkNotNull(victim)
|
||||
plugin.logger.info(
|
||||
"Invuln overdamage (iron) debug (post): events=${events.size} " +
|
||||
"noDamageTicks=${currentVictim.noDamageTicks} lastDamage=${currentVictim.lastDamage} " +
|
||||
"lastEventDamage=${events.lastOrNull()?.damage} lastEventFinal=${events.lastOrNull()?.finalDamage}"
|
||||
)
|
||||
}
|
||||
events.size.shouldBeExactly(2)
|
||||
} finally {
|
||||
HandlerList.unregisterAll(listener)
|
||||
runSync {
|
||||
fake?.removePlayer()
|
||||
victim?.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("zero vanilla damage should still fire after lastDamage reset").config(
|
||||
enabled = false
|
||||
) {
|
||||
// Disabled: behaviour diverges across versions; we will revisit once vanilla expectations are finalised.
|
||||
val events = mutableListOf<EntityDamageByEntityEvent>()
|
||||
lateinit var attacker: Player
|
||||
var victim: LivingEntity? = null
|
||||
var fake: FakePlayer? = null
|
||||
|
||||
val listener = object : Listener {
|
||||
@EventHandler
|
||||
fun onDamage(event: EntityDamageByEntityEvent) {
|
||||
val currentVictim = victim ?: return
|
||||
if (event.entity.uniqueId == currentVictim.uniqueId &&
|
||||
event.damager.uniqueId == attacker.uniqueId
|
||||
) {
|
||||
events.add(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
|
||||
val victimLocation = Location(world, 1.2, 100.0, 0.0)
|
||||
|
||||
val (fakeA, playerA) = spawnAttacker(attackerLocation)
|
||||
fake = fakeA
|
||||
attacker = playerA
|
||||
val spawnedVictim = spawnVictim(victimLocation)
|
||||
victim = spawnedVictim
|
||||
|
||||
Bukkit.getPluginManager().registerEvents(listener, plugin)
|
||||
|
||||
equip(attacker, ItemStack(Material.DIAMOND_SWORD))
|
||||
attackCompat(attacker, spawnedVictim)
|
||||
}
|
||||
|
||||
delayTicks(2) // allow MONITOR handler to set lastDamage to 0
|
||||
|
||||
runSync {
|
||||
val currentVictim = checkNotNull(victim)
|
||||
currentVictim.noDamageTicks = currentVictim.maximumNoDamageTicks
|
||||
currentVictim.lastDamage.shouldBe(0.0)
|
||||
|
||||
val effect = consumeWeaknessPotion(attacker)
|
||||
attacker.addPotionEffect(effect, true)
|
||||
val woodenSword = XMaterial.WOODEN_SWORD.parseItem() ?: ItemStack(Material.STONE_SWORD)
|
||||
equip(attacker, woodenSword)
|
||||
attackCompat(attacker, currentVictim)
|
||||
}
|
||||
|
||||
delayTicks(3)
|
||||
events.size.shouldBeExactly(2)
|
||||
} finally {
|
||||
HandlerList.unregisterAll(listener)
|
||||
runSync {
|
||||
fake?.removePlayer()
|
||||
victim?.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("weakness should not store negative last damage values") {
|
||||
var attacker: Player? = null
|
||||
var victim: LivingEntity? = null
|
||||
var fake: FakePlayer? = null
|
||||
var edbelListener: Listener? = null
|
||||
var lastDamagesField: java.lang.reflect.Field? = null
|
||||
var damageEvents = 0
|
||||
val damageListener = object : Listener {
|
||||
@EventHandler(ignoreCancelled = false)
|
||||
fun onDamage(event: EntityDamageByEntityEvent) {
|
||||
val currentVictim = victim ?: return
|
||||
val currentAttacker = attacker ?: return
|
||||
if (event.entity.uniqueId != currentVictim.uniqueId) return
|
||||
if (event.damager.uniqueId != currentAttacker.uniqueId) return
|
||||
damageEvents += 1
|
||||
}
|
||||
}
|
||||
|
||||
val oldWeaknessModifier = ocm.config.getDouble("old-potion-effects.weakness.modifier")
|
||||
val oldWeaknessMultiplier = ocm.config.getBoolean("old-potion-effects.weakness.multiplier")
|
||||
|
||||
try {
|
||||
runSync {
|
||||
ocm.config.set("old-potion-effects.weakness.modifier", -10.0)
|
||||
ocm.config.set("old-potion-effects.weakness.multiplier", false)
|
||||
ocm.saveConfig()
|
||||
Config.reload()
|
||||
|
||||
edbelListener = HandlerList.getRegisteredListeners(ocm)
|
||||
.map { it.listener }
|
||||
.firstOrNull { it.javaClass.name.endsWith("EntityDamageByEntityListener") }
|
||||
?: error("EntityDamageByEntityListener not registered")
|
||||
lastDamagesField = edbelListener?.javaClass?.getDeclaredField("lastDamages")?.apply {
|
||||
isAccessible = true
|
||||
} ?: error("Failed to resolve lastDamages field")
|
||||
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val attackerLocation = Location(world, 0.0, 100.0, 0.0)
|
||||
val victimLocation = Location(world, 1.2, 100.0, 0.0)
|
||||
|
||||
val (fakeA, playerA) = spawnAttacker(attackerLocation)
|
||||
fake = fakeA
|
||||
attacker = playerA
|
||||
val spawnedVictim = spawnVictim(victimLocation)
|
||||
victim = spawnedVictim
|
||||
|
||||
Bukkit.getPluginManager().registerEvents(damageListener, plugin)
|
||||
|
||||
val effect = consumeWeaknessPotion(playerA)
|
||||
playerA.addPotionEffect(effect, true)
|
||||
equip(playerA, ItemStack(Material.IRON_SWORD))
|
||||
attackCompat(playerA, spawnedVictim)
|
||||
val currentVictim = checkNotNull(victim)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val lastDamages = lastDamagesField?.get(edbelListener) as Map<UUID, Double>
|
||||
val stored = lastDamages[currentVictim.uniqueId]
|
||||
?: error("No stored last damage for victim (events=$damageEvents)")
|
||||
damageEvents.shouldBeExactly(1)
|
||||
stored.shouldBeGreaterThanOrEqual(0.0)
|
||||
}
|
||||
} finally {
|
||||
HandlerList.unregisterAll(damageListener)
|
||||
runSync {
|
||||
ocm.config.set("old-potion-effects.weakness.modifier", oldWeaknessModifier)
|
||||
ocm.config.set("old-potion-effects.weakness.multiplier", oldWeaknessMultiplier)
|
||||
ocm.saveConfig()
|
||||
Config.reload()
|
||||
fake?.removePlayer()
|
||||
victim?.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("environmental damage above baseline should apply during invulnerability") {
|
||||
var victim: LivingEntity? = null
|
||||
var edbelListener: Listener? = null
|
||||
var lastDamagesField: java.lang.reflect.Field? = null
|
||||
var capturedEvent: EntityDamageEvent? = null
|
||||
|
||||
val eventListener = object : Listener {
|
||||
@EventHandler(ignoreCancelled = false)
|
||||
fun onDamage(event: EntityDamageEvent) {
|
||||
val currentVictim = victim ?: return
|
||||
if (event.entity.uniqueId != currentVictim.uniqueId) return
|
||||
if (event is EntityDamageByEntityEvent) return
|
||||
capturedEvent = event
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runSync {
|
||||
edbelListener = HandlerList.getRegisteredListeners(ocm)
|
||||
.map { it.listener }
|
||||
.firstOrNull { it.javaClass.name.endsWith("EntityDamageByEntityListener") }
|
||||
?: error("EntityDamageByEntityListener not registered")
|
||||
lastDamagesField = edbelListener?.javaClass?.getDeclaredField("lastDamages")?.apply {
|
||||
isAccessible = true
|
||||
} ?: error("Failed to resolve lastDamages field")
|
||||
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val victimLocation = Location(world, 1.2, 100.0, 0.0)
|
||||
val spawnedVictim = spawnVictim(victimLocation)
|
||||
victim = spawnedVictim
|
||||
spawnedVictim.maximumNoDamageTicks = 20
|
||||
spawnedVictim.noDamageTicks = 20
|
||||
spawnedVictim.lastDamage = 0.0
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val lastDamages = lastDamagesField?.get(edbelListener) as MutableMap<UUID, Double>
|
||||
lastDamages[spawnedVictim.uniqueId] = 5.0
|
||||
|
||||
Bukkit.getPluginManager().registerEvents(eventListener, plugin)
|
||||
|
||||
val event = EntityDamageEvent(
|
||||
spawnedVictim,
|
||||
EntityDamageEvent.DamageCause.FALL,
|
||||
12.0
|
||||
)
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
}
|
||||
|
||||
runSync {
|
||||
val event = checkNotNull(capturedEvent)
|
||||
event.isCancelled.shouldBe(false)
|
||||
event.damage.shouldBe(7.0)
|
||||
}
|
||||
} finally {
|
||||
HandlerList.unregisterAll(eventListener)
|
||||
runSync {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val lastDamages = lastDamagesField?.get(edbelListener) as? MutableMap<UUID, Double>
|
||||
lastDamages?.remove(victim?.uniqueId)
|
||||
victim?.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@file:Suppress("ktlint:standard:package-name")
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import com.github.ajalt.mordant.TermColors
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.common.KotestInternal
|
||||
import io.kotest.core.config.AbstractProjectConfig
|
||||
import io.kotest.core.extensions.TestCaseExtension
|
||||
import io.kotest.core.spec.IsolationMode
|
||||
import io.kotest.core.test.TestCase
|
||||
import io.kotest.core.test.TestCaseOrder
|
||||
import io.kotest.core.test.TestResult
|
||||
import io.kotest.engine.TestEngineLauncher
|
||||
import io.kotest.engine.listener.AbstractTestEngineListener
|
||||
import io.kotest.engine.listener.CompositeTestEngineListener
|
||||
import io.kotest.engine.listener.EnhancedConsoleTestEngineListener
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
object KotestProjectConfig : AbstractProjectConfig() {
|
||||
override val isolationMode = IsolationMode.SingleInstance
|
||||
override val concurrentSpecs = 1
|
||||
override val concurrentTests = 1
|
||||
override val testCaseOrder = TestCaseOrder.Sequential
|
||||
}
|
||||
|
||||
class BukkitMainThreadDispatcher(private val plugin: JavaPlugin) : CoroutineDispatcher() {
|
||||
override fun dispatch(context: CoroutineContext, block: Runnable) {
|
||||
Bukkit.getScheduler().runTask(plugin, block)
|
||||
}
|
||||
}
|
||||
|
||||
class MainThreadDispatcherExtension(private val plugin: JavaPlugin) : TestCaseExtension {
|
||||
override suspend fun intercept(testCase: TestCase, execute: suspend (TestCase) -> TestResult): TestResult {
|
||||
val dispatcher = BukkitMainThreadDispatcher(plugin)
|
||||
val newContext = coroutineContext + dispatcher
|
||||
return withContext(newContext) {
|
||||
execute(testCase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object KotestRunner {
|
||||
@OptIn(KotestInternal::class)
|
||||
@JvmStatic
|
||||
fun run(plugin: JavaPlugin) {
|
||||
// Schedule test asynchronously to avoid deadlock.
|
||||
Bukkit.getScheduler().runTaskAsynchronously(
|
||||
plugin,
|
||||
Runnable {
|
||||
try {
|
||||
var hasFailures = false
|
||||
val failureLines = ArrayList<String>(16)
|
||||
|
||||
fun throwableFromResult(result: TestResult): Throwable? {
|
||||
// Avoid depending on Kotest internals: fetch any Throwable via reflection for cross-version tolerance.
|
||||
val candidateGetters =
|
||||
listOf(
|
||||
"getErrorOrNull",
|
||||
"getCauseOrNull",
|
||||
"getThrowableOrNull",
|
||||
"getFailureOrNull"
|
||||
)
|
||||
for (getter in candidateGetters) {
|
||||
val m =
|
||||
result::class.java.methods.firstOrNull { it.name == getter && it.parameterCount == 0 }
|
||||
?: continue
|
||||
val t = runCatching { m.invoke(result) }.getOrNull() as? Throwable
|
||||
if (t != null) return t
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun formatFailure(testCase: TestCase, result: TestResult): String {
|
||||
val specName = testCase.spec::class.qualifiedName ?: testCase.spec::class.java.name
|
||||
val testName = testCase.displayName
|
||||
val t = throwableFromResult(result)
|
||||
if (t == null) return "$specName, $testName"
|
||||
|
||||
val message =
|
||||
t.message
|
||||
?.lineSequence()
|
||||
?.firstOrNull()
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
val head =
|
||||
if (message.isNotEmpty()) {
|
||||
"${t::class.java.simpleName}: $message"
|
||||
} else {
|
||||
t::class.java.simpleName
|
||||
}
|
||||
val frame = t.stackTrace.firstOrNull()
|
||||
val at =
|
||||
if (frame != null) {
|
||||
" (${frame.fileName}:${frame.lineNumber})"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return "$specName, $testName -- $head$at"
|
||||
}
|
||||
|
||||
val listener =
|
||||
object : AbstractTestEngineListener() {
|
||||
override suspend fun testFinished(testCase: TestCase, result: TestResult) {
|
||||
if (result.isFailure || result.isError) {
|
||||
hasFailures = true
|
||||
if (failureLines.size < 25) {
|
||||
failureLines.add(formatFailure(testCase, result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun engineFinished(t: List<Throwable>) {
|
||||
val success = t.isEmpty() && !hasFailures
|
||||
TestResultWriter.writeFailureSummary(plugin, failureLines)
|
||||
TestResultWriter.writeAndShutdown(plugin, success)
|
||||
}
|
||||
}
|
||||
|
||||
val compositeListener =
|
||||
CompositeTestEngineListener(
|
||||
listOf(
|
||||
EnhancedConsoleTestEngineListener(TermColors()),
|
||||
listener
|
||||
)
|
||||
)
|
||||
|
||||
TestEngineLauncher()
|
||||
.withListener(compositeListener)
|
||||
.withProjectConfig(KotestProjectConfig)
|
||||
.withClasses(
|
||||
ConfigMigrationIntegrationTest::class,
|
||||
ModesetRulesIntegrationTest::class,
|
||||
PlayerModuleOverrideApiIntegrationTest::class,
|
||||
DisableOffhandIntegrationTest::class,
|
||||
DisableOffhandReflectionIntegrationTest::class,
|
||||
InGameTesterIntegrationTest::class,
|
||||
CopperToolsIntegrationTest::class,
|
||||
OldPotionEffectsIntegrationTest::class,
|
||||
InvulnerabilityDamageIntegrationTest::class,
|
||||
MobInvulnerabilityIntegrationTest::class,
|
||||
FireAspectOverdamageIntegrationTest::class,
|
||||
OldCriticalHitsIntegrationTest::class,
|
||||
OldToolDamageMobIntegrationTest::class,
|
||||
WeaponDurabilityIntegrationTest::class,
|
||||
GoldenAppleIntegrationTest::class,
|
||||
OldArmourDurabilityIntegrationTest::class,
|
||||
OldArmourStrengthModesetIntegrationTest::class,
|
||||
MixedModePvPIntegrationTest::class,
|
||||
PlayerKnockbackIntegrationTest::class,
|
||||
AttackCooldownTrackerIntegrationTest::class,
|
||||
AttackCooldownHeldItemIntegrationTest::class,
|
||||
PlayerRegenIntegrationTest::class,
|
||||
FishingRodVelocityIntegrationTest::class,
|
||||
SwordSweepIntegrationTest::class,
|
||||
PacketCancellationIntegrationTest::class,
|
||||
EnderpearlCooldownIntegrationTest::class,
|
||||
SpigotFunctionChooserIntegrationTest::class,
|
||||
ChorusFruitIntegrationTest::class,
|
||||
CustomWeaponDamageIntegrationTest::class,
|
||||
ToolDamageTooltipIntegrationTest::class,
|
||||
SwordBlockingIntegrationTest::class,
|
||||
ConsumableComponentIntegrationTest::class,
|
||||
PaperSwordBlockingDamageReductionIntegrationTest::class,
|
||||
AttackRangeIntegrationTest::class
|
||||
).launch()
|
||||
} catch (e: Throwable) {
|
||||
plugin.logger.severe("Failed to execute Kotest runner: ${e.message}")
|
||||
TestResultWriter.writeAndShutdown(plugin, false, e)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import com.mojang.authlib.GameProfile
|
||||
import io.netty.channel.ChannelInboundHandlerAdapter
|
||||
import io.netty.channel.embedded.EmbeddedChannel
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.player.AsyncPlayerPreLoginEvent
|
||||
import org.bukkit.event.player.PlayerJoinEvent
|
||||
import org.bukkit.event.player.PlayerPreLoginEvent
|
||||
import org.bukkit.event.player.PlayerQuitEvent
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.lang.reflect.Field
|
||||
import java.lang.reflect.Method
|
||||
import java.net.InetAddress
|
||||
import java.util.UUID
|
||||
|
||||
internal class LegacyFakePlayer12(
|
||||
private val plugin: JavaPlugin,
|
||||
val uuid: UUID,
|
||||
val name: String
|
||||
) {
|
||||
private val cbVersion: String = Bukkit.getServer().javaClass.`package`.name.substringAfterLast('.')
|
||||
var entityPlayer: Any? = null
|
||||
private set
|
||||
var bukkitPlayer: Player? = null
|
||||
private set
|
||||
|
||||
private fun nmsClass(simpleName: String): Class<*> =
|
||||
Class.forName("net.minecraft.server.$cbVersion.$simpleName")
|
||||
|
||||
private fun craftClass(simpleName: String): Class<*> =
|
||||
Class.forName("org.bukkit.craftbukkit.$cbVersion.$simpleName")
|
||||
|
||||
fun spawn(location: Location) {
|
||||
plugin.logger.info("Spawn: Starting (legacy $cbVersion)")
|
||||
val world = location.world ?: throw IllegalArgumentException("Location has no world!")
|
||||
|
||||
val craftWorld = craftClass("CraftWorld").cast(world)
|
||||
val worldServer = craftWorld.javaClass.getMethod("getHandle").invoke(craftWorld)
|
||||
|
||||
val craftServer = craftClass("CraftServer").cast(Bukkit.getServer())
|
||||
val minecraftServer = craftServer.javaClass.getMethod("getServer").invoke(craftServer)
|
||||
|
||||
val entityPlayer = createEntityPlayer(minecraftServer, worldServer)
|
||||
this.entityPlayer = entityPlayer
|
||||
val bukkitPlayer = entityPlayer.javaClass.getMethod("getBukkitEntity").invoke(entityPlayer) as Player
|
||||
this.bukkitPlayer = bukkitPlayer
|
||||
|
||||
firePreLoginEvents()
|
||||
|
||||
val playerList = minecraftServer.javaClass.getMethod("getPlayerList").invoke(minecraftServer)
|
||||
invokeMethodIfExists(playerList, "a", entityPlayer)
|
||||
|
||||
setPositionRotation(entityPlayer, location)
|
||||
setDataWatcherFlags(entityPlayer)
|
||||
|
||||
spawnInWorld(entityPlayer, worldServer)
|
||||
setGameMode(entityPlayer, worldServer)
|
||||
setupConnection(entityPlayer, minecraftServer)
|
||||
|
||||
addToPlayerChunkMap(worldServer, entityPlayer)
|
||||
addToPlayerList(playerList, entityPlayer)
|
||||
updatePlayerListMaps(playerList, entityPlayer)
|
||||
|
||||
val joinMessage = "§e$name joined the game"
|
||||
val joinEvent = PlayerJoinEvent(bukkitPlayer, joinMessage)
|
||||
Bukkit.getPluginManager().callEvent(joinEvent)
|
||||
broadcastMessage(joinEvent.joinMessage)
|
||||
|
||||
sendSpawnPackets(entityPlayer)
|
||||
addEntityToWorld(worldServer, entityPlayer)
|
||||
|
||||
Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, Runnable {
|
||||
runCatching { invokeMethod(entityPlayer, "playerTick") }
|
||||
}, 1L, 1L)
|
||||
|
||||
plugin.logger.info("Spawn: completed successfully (legacy)")
|
||||
}
|
||||
|
||||
fun removePlayer() {
|
||||
val entityPlayer = entityPlayer ?: return
|
||||
val bukkitPlayer = bukkitPlayer ?: return
|
||||
|
||||
val craftServer = craftClass("CraftServer").cast(Bukkit.getServer())
|
||||
val minecraftServer = craftServer.javaClass.getMethod("getServer").invoke(craftServer)
|
||||
val playerList = minecraftServer.javaClass.getMethod("getPlayerList").invoke(minecraftServer)
|
||||
|
||||
val quitMessage = "§e$name left the game"
|
||||
val quitEvent = PlayerQuitEvent(bukkitPlayer, quitMessage)
|
||||
Bukkit.getPluginManager().callEvent(quitEvent)
|
||||
|
||||
val worldServer = getWorldServer(entityPlayer)
|
||||
val playerChunkMap = getPlayerChunkMap(worldServer)
|
||||
invokeMethodIfExists(playerChunkMap, "removePlayer", entityPlayer)
|
||||
invokeMethodIfExists(worldServer, "removeEntity", entityPlayer)
|
||||
|
||||
bukkitPlayer.kickPlayer(quitMessage)
|
||||
|
||||
removeFromPlayerList(playerList, entityPlayer)
|
||||
removeFromPlayerMaps(playerList, entityPlayer)
|
||||
|
||||
sendRemovePackets(entityPlayer)
|
||||
|
||||
invokeMethodIfExists(playerList, "savePlayerFile", entityPlayer)
|
||||
}
|
||||
|
||||
fun startUsingOffhand() {
|
||||
val entityPlayer = entityPlayer ?: return
|
||||
val enumHandClass = nmsClass("EnumHand")
|
||||
val offHand = enumValue(enumHandClass, "OFF_HAND")
|
||||
val candidateNames = arrayOf("c", "a", "startUsingItem")
|
||||
for (name in candidateNames) {
|
||||
val method = findMethod(entityPlayer.javaClass, name, arrayOf(offHand), ignoreMissing = true)
|
||||
if (method != null) {
|
||||
method.invoke(entityPlayer, offHand)
|
||||
return
|
||||
}
|
||||
}
|
||||
val fallback = entityPlayer.javaClass.methods.firstOrNull { method ->
|
||||
method.parameterTypes.size == 1 && method.parameterTypes[0] == enumHandClass
|
||||
}
|
||||
fallback?.invoke(entityPlayer, offHand)
|
||||
}
|
||||
|
||||
fun getConnection(serverPlayer: Any): Any {
|
||||
val connectionField = findField(serverPlayer.javaClass, "playerConnection")
|
||||
?: throw NoSuchFieldException("playerConnection not found on ${serverPlayer.javaClass.name}")
|
||||
return connectionField.get(serverPlayer)
|
||||
}
|
||||
|
||||
private fun createEntityPlayer(minecraftServer: Any, worldServer: Any): Any {
|
||||
val entityPlayerClass = nmsClass("EntityPlayer")
|
||||
val playerInteractManagerClass = nmsClass("PlayerInteractManager")
|
||||
val pimCtor = playerInteractManagerClass.constructors.firstOrNull { ctor ->
|
||||
ctor.parameterTypes.size == 1 && ctor.parameterTypes[0].isAssignableFrom(worldServer.javaClass)
|
||||
} ?: playerInteractManagerClass.constructors.first()
|
||||
val playerInteractManager = pimCtor.newInstance(worldServer)
|
||||
val gameProfile = GameProfile(uuid, name)
|
||||
val ctor = entityPlayerClass.constructors.firstOrNull { ctor ->
|
||||
val params = ctor.parameterTypes
|
||||
params.size == 4 &&
|
||||
params[0].isAssignableFrom(minecraftServer.javaClass) &&
|
||||
params[1].isAssignableFrom(worldServer.javaClass) &&
|
||||
params[2] == GameProfile::class.java &&
|
||||
params[3].isAssignableFrom(playerInteractManagerClass)
|
||||
} ?: entityPlayerClass.constructors.first()
|
||||
return ctor.newInstance(minecraftServer, worldServer, gameProfile, playerInteractManager)
|
||||
}
|
||||
|
||||
private fun firePreLoginEvents() {
|
||||
try {
|
||||
val address = InetAddress.getByName("127.0.0.1")
|
||||
val asyncPreLogin = AsyncPlayerPreLoginEvent(name, address, uuid)
|
||||
val preLogin = PlayerPreLoginEvent(name, address, uuid)
|
||||
Thread { Bukkit.getPluginManager().callEvent(asyncPreLogin) }.start()
|
||||
Bukkit.getPluginManager().callEvent(preLogin)
|
||||
} catch (e: Exception) {
|
||||
plugin.logger.warning("Failed to fire pre-login events: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPositionRotation(entityPlayer: Any, location: Location) {
|
||||
val method = entityPlayer.javaClass.getMethod(
|
||||
"setPositionRotation",
|
||||
Double::class.javaPrimitiveType,
|
||||
Double::class.javaPrimitiveType,
|
||||
Double::class.javaPrimitiveType,
|
||||
Float::class.javaPrimitiveType,
|
||||
Float::class.javaPrimitiveType
|
||||
)
|
||||
method.invoke(
|
||||
entityPlayer,
|
||||
location.x,
|
||||
location.y,
|
||||
location.z,
|
||||
location.yaw,
|
||||
location.pitch
|
||||
)
|
||||
}
|
||||
|
||||
private fun setDataWatcherFlags(entityPlayer: Any) {
|
||||
runCatching {
|
||||
val dataWatcher = invokeMethod(entityPlayer, "getDataWatcher")
|
||||
val dataWatcherRegistryClass = nmsClass("DataWatcherRegistry")
|
||||
val serializer = dataWatcherRegistryClass.getField("a").get(null)
|
||||
val dataWatcherObject = invokeMethod(serializer, "a", 13)
|
||||
val setMethod = dataWatcher.javaClass.methods.firstOrNull { it.name == "set" && it.parameterCount == 2 }
|
||||
setMethod?.invoke(dataWatcher, dataWatcherObject, 127.toByte())
|
||||
}
|
||||
}
|
||||
|
||||
private fun spawnInWorld(entityPlayer: Any, worldServer: Any) {
|
||||
invokeMethodIfExists(entityPlayer, "spawnIn", worldServer)
|
||||
val playerInteractManager = findField(entityPlayer.javaClass, "playerInteractManager")?.get(entityPlayer)
|
||||
if (playerInteractManager != null) {
|
||||
invokeMethodIfExists(playerInteractManager, "a", worldServer)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setGameMode(entityPlayer: Any, worldServer: Any) {
|
||||
val playerInteractManager = findField(entityPlayer.javaClass, "playerInteractManager")?.get(entityPlayer) ?: return
|
||||
val enumGamemodeClass = nmsClass("EnumGamemode")
|
||||
val gameMode = Bukkit.getServer().defaultGameMode
|
||||
val enumValue = enumValue(enumGamemodeClass, gameMode)
|
||||
invokeMethodIfExists(playerInteractManager, "b", enumValue)
|
||||
}
|
||||
|
||||
private fun setupConnection(entityPlayer: Any, minecraftServer: Any) {
|
||||
val networkManagerClass = nmsClass("NetworkManager")
|
||||
val enumProtocolDirectionClass = nmsClass("EnumProtocolDirection")
|
||||
val serverbound = enumValue(enumProtocolDirectionClass, "SERVERBOUND")
|
||||
val networkManager = networkManagerClass
|
||||
.getConstructor(enumProtocolDirectionClass)
|
||||
.newInstance(serverbound)
|
||||
|
||||
val playerConnectionClass = nmsClass("PlayerConnection")
|
||||
val pcCtor = playerConnectionClass.constructors.firstOrNull { ctor ->
|
||||
val params = ctor.parameterTypes
|
||||
params.size == 3 &&
|
||||
params[0].isAssignableFrom(minecraftServer.javaClass) &&
|
||||
params[1].isAssignableFrom(networkManagerClass) &&
|
||||
params[2].isAssignableFrom(entityPlayer.javaClass)
|
||||
} ?: playerConnectionClass.constructors.first()
|
||||
val playerConnection = pcCtor.newInstance(minecraftServer, networkManager, entityPlayer)
|
||||
|
||||
setFieldValue(entityPlayer, "playerConnection", playerConnection)
|
||||
val channel = EmbeddedChannel(ChannelInboundHandlerAdapter())
|
||||
setFieldValue(networkManager, "channel", channel)
|
||||
runCatching { channel.close() }
|
||||
}
|
||||
|
||||
private fun addToPlayerChunkMap(worldServer: Any, entityPlayer: Any) {
|
||||
val playerChunkMap = getPlayerChunkMap(worldServer)
|
||||
invokeMethodIfExists(playerChunkMap, "addPlayer", entityPlayer)
|
||||
}
|
||||
|
||||
private fun addToPlayerList(playerList: Any, entityPlayer: Any) {
|
||||
runCatching {
|
||||
val playersField = findField(playerList.javaClass, "players")
|
||||
@Suppress("UNCHECKED_CAST") val players = playersField?.get(playerList) as? MutableCollection<Any>
|
||||
players?.add(entityPlayer)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePlayerListMaps(playerList: Any, entityPlayer: Any) {
|
||||
runCatching {
|
||||
val byUuidField = findField(playerList.javaClass, "j")
|
||||
@Suppress("UNCHECKED_CAST") val byUuid = byUuidField?.get(playerList) as? MutableMap<Any, Any>
|
||||
byUuid?.put(uuid, entityPlayer)
|
||||
}
|
||||
runCatching {
|
||||
val byNameField = findField(playerList.javaClass, "playersByName")
|
||||
@Suppress("UNCHECKED_CAST") val byName = byNameField?.get(playerList) as? MutableMap<Any, Any>
|
||||
byName?.put(name, entityPlayer)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendSpawnPackets(entityPlayer: Any) {
|
||||
val packetPlayOutPlayerInfo = nmsClass("PacketPlayOutPlayerInfo")
|
||||
val actionClass = nmsClass("PacketPlayOutPlayerInfo\$EnumPlayerInfoAction")
|
||||
val addAction = enumValue(actionClass, "ADD_PLAYER")
|
||||
val entityArray = java.lang.reflect.Array.newInstance(entityPlayer.javaClass, 1)
|
||||
java.lang.reflect.Array.set(entityArray, 0, entityPlayer)
|
||||
val infoPacket = packetPlayOutPlayerInfo
|
||||
.getConstructor(actionClass, entityArray.javaClass)
|
||||
.newInstance(addAction, entityArray)
|
||||
|
||||
val namedSpawnPacket = createSingleArgPacket("PacketPlayOutNamedEntitySpawn", entityPlayer)
|
||||
|
||||
val craftPlayerClass = craftClass("entity.CraftPlayer")
|
||||
for (player in Bukkit.getOnlinePlayers()) {
|
||||
val craftPlayer = craftPlayerClass.cast(player)
|
||||
val handle = craftPlayerClass.getMethod("getHandle").invoke(craftPlayer)
|
||||
val connection = findField(handle.javaClass, "playerConnection")?.get(handle) ?: continue
|
||||
sendPacket(connection, infoPacket)
|
||||
if (namedSpawnPacket != null) {
|
||||
sendPacket(connection, namedSpawnPacket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendRemovePackets(entityPlayer: Any) {
|
||||
val packetDestroy = createEntityDestroyPacket(entityPlayer)
|
||||
val packetInfo = createPlayerInfoPacket("REMOVE_PLAYER", entityPlayer)
|
||||
val craftPlayerClass = craftClass("entity.CraftPlayer")
|
||||
for (player in Bukkit.getOnlinePlayers()) {
|
||||
val craftPlayer = craftPlayerClass.cast(player)
|
||||
val handle = craftPlayerClass.getMethod("getHandle").invoke(craftPlayer)
|
||||
val connection = findField(handle.javaClass, "playerConnection")?.get(handle) ?: continue
|
||||
if (packetDestroy != null) sendPacket(connection, packetDestroy)
|
||||
if (packetInfo != null) sendPacket(connection, packetInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPlayerInfoPacket(actionName: String, entityPlayer: Any): Any? {
|
||||
return runCatching {
|
||||
val packetPlayOutPlayerInfo = nmsClass("PacketPlayOutPlayerInfo")
|
||||
val actionClass = nmsClass("PacketPlayOutPlayerInfo\$EnumPlayerInfoAction")
|
||||
val action = enumValue(actionClass, actionName)
|
||||
val entityArray = java.lang.reflect.Array.newInstance(entityPlayer.javaClass, 1)
|
||||
java.lang.reflect.Array.set(entityArray, 0, entityPlayer)
|
||||
packetPlayOutPlayerInfo
|
||||
.getConstructor(actionClass, entityArray.javaClass)
|
||||
.newInstance(action, entityArray)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun createEntityDestroyPacket(entityPlayer: Any): Any? {
|
||||
return runCatching {
|
||||
val packetClass = nmsClass("PacketPlayOutEntityDestroy")
|
||||
val getIdMethod = entityPlayer.javaClass.getMethod("getId")
|
||||
val entityId = getIdMethod.invoke(entityPlayer) as Int
|
||||
val ids = intArrayOf(entityId)
|
||||
packetClass.getConstructor(IntArray::class.java).newInstance(ids)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun createSingleArgPacket(className: String, entityPlayer: Any): Any? {
|
||||
return runCatching {
|
||||
val packetClass = nmsClass(className)
|
||||
val ctor = packetClass.constructors.firstOrNull { ctor ->
|
||||
ctor.parameterTypes.size == 1 && ctor.parameterTypes[0].isAssignableFrom(entityPlayer.javaClass)
|
||||
} ?: packetClass.constructors.firstOrNull { ctor ->
|
||||
ctor.parameterTypes.size == 1 && ctor.parameterTypes[0].isAssignableFrom(entityPlayer.javaClass.superclass)
|
||||
}
|
||||
ctor?.newInstance(entityPlayer)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun addEntityToWorld(worldServer: Any, entityPlayer: Any) {
|
||||
invokeMethodIfExists(worldServer, "addEntity", entityPlayer)
|
||||
}
|
||||
|
||||
private fun getWorldServer(entityPlayer: Any): Any {
|
||||
val worldField = findField(entityPlayer.javaClass, "world")
|
||||
return worldField?.get(entityPlayer) ?: invokeMethod(entityPlayer, "getWorld")
|
||||
}
|
||||
|
||||
private fun getPlayerChunkMap(worldServer: Any): Any {
|
||||
val getPlayerChunkMap = worldServer.javaClass.methods.firstOrNull { it.name == "getPlayerChunkMap" }
|
||||
return if (getPlayerChunkMap != null) {
|
||||
getPlayerChunkMap.invoke(worldServer)
|
||||
} else {
|
||||
findField(worldServer.javaClass, "playerChunkMap")?.get(worldServer)
|
||||
?: throw NoSuchFieldException("playerChunkMap not found on ${worldServer.javaClass.name}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeFromPlayerList(playerList: Any, entityPlayer: Any) {
|
||||
runCatching {
|
||||
val playersField = findField(playerList.javaClass, "players")
|
||||
@Suppress("UNCHECKED_CAST") val players = playersField?.get(playerList) as? MutableCollection<Any>
|
||||
players?.remove(entityPlayer)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeFromPlayerMaps(playerList: Any, entityPlayer: Any) {
|
||||
runCatching {
|
||||
val byUuidField = findField(playerList.javaClass, "j")
|
||||
@Suppress("UNCHECKED_CAST") val byUuid = byUuidField?.get(playerList) as? MutableMap<Any, Any>
|
||||
byUuid?.remove(uuid)
|
||||
}
|
||||
runCatching {
|
||||
val byNameField = findField(playerList.javaClass, "playersByName")
|
||||
@Suppress("UNCHECKED_CAST") val byName = byNameField?.get(playerList) as? MutableMap<Any, Any>
|
||||
byName?.remove(name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendPacket(connection: Any, packet: Any) {
|
||||
val packetClass = nmsClass("Packet")
|
||||
val sendMethod = connection.javaClass.getMethod("sendPacket", packetClass)
|
||||
sendMethod.invoke(connection, packet)
|
||||
}
|
||||
|
||||
private fun broadcastMessage(message: String?) {
|
||||
if (message.isNullOrEmpty()) return
|
||||
for (player in Bukkit.getOnlinePlayers()) {
|
||||
player.sendMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeMethod(target: Any, name: String, vararg args: Any?): Any {
|
||||
val method = findMethod(target.javaClass, name, args)
|
||||
?: throw NoSuchMethodException("Method '$name' not found on ${target.javaClass.name}")
|
||||
return method.invoke(target, *args)
|
||||
}
|
||||
|
||||
private fun invokeMethodIfExists(target: Any, name: String, vararg args: Any?) {
|
||||
val method = findMethod(target.javaClass, name, args, ignoreMissing = true) ?: return
|
||||
method.invoke(target, *args)
|
||||
}
|
||||
|
||||
private fun findMethod(
|
||||
clazz: Class<*>,
|
||||
name: String,
|
||||
args: Array<out Any?>,
|
||||
ignoreMissing: Boolean = false
|
||||
): Method? {
|
||||
val candidates = (clazz.methods + clazz.declaredMethods).filter { it.name == name }
|
||||
val method = candidates.firstOrNull { method ->
|
||||
if (method.parameterTypes.size != args.size) return@firstOrNull false
|
||||
method.parameterTypes.indices.all { idx ->
|
||||
val arg = args[idx]
|
||||
arg == null || method.parameterTypes[idx].isAssignableFrom(arg.javaClass)
|
||||
}
|
||||
} ?: candidates.firstOrNull { it.parameterTypes.size == args.size }
|
||||
if (method == null && !ignoreMissing) {
|
||||
throw NoSuchMethodException("Method '$name' not found on ${clazz.name}")
|
||||
}
|
||||
method?.isAccessible = true
|
||||
return method
|
||||
}
|
||||
|
||||
private fun findField(clazz: Class<*>, name: String): Field? {
|
||||
var current: Class<*>? = clazz
|
||||
while (current != null) {
|
||||
runCatching {
|
||||
val field = current.getDeclaredField(name)
|
||||
field.isAccessible = true
|
||||
return field
|
||||
}
|
||||
current = current.superclass
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun setFieldValue(target: Any, name: String, value: Any?) {
|
||||
val field = findField(target.javaClass, name) ?: return
|
||||
field.set(target, value)
|
||||
}
|
||||
|
||||
private fun enumValue(enumClass: Class<*>, name: String): Any {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val enumType = enumClass as Class<out Enum<*>>
|
||||
return java.lang.Enum.valueOf(enumType, name)
|
||||
}
|
||||
|
||||
private fun enumValue(enumClass: Class<*>, gameMode: GameMode): Any {
|
||||
return enumValue(enumClass, gameMode.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import com.mojang.authlib.GameProfile
|
||||
import io.netty.channel.ChannelInboundHandlerAdapter
|
||||
import io.netty.channel.ChannelOutboundHandlerAdapter
|
||||
import io.netty.channel.embedded.EmbeddedChannel
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.player.AsyncPlayerPreLoginEvent
|
||||
import org.bukkit.event.player.PlayerPreLoginEvent
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.net.InetAddress
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Fake player for 1.9.x (v1_9_R2). Uses PlayerList#a(NetworkManager, EntityPlayer)
|
||||
* so the server treats it like a real online player (damage/knockback events).
|
||||
*/
|
||||
internal class LegacyFakePlayer9(
|
||||
private val plugin: JavaPlugin,
|
||||
val uuid: UUID,
|
||||
val name: String
|
||||
) {
|
||||
private val cbVersion: String = Bukkit.getServer().javaClass.`package`.name.substringAfterLast('.')
|
||||
var entityPlayer: Any? = null
|
||||
private set
|
||||
var bukkitPlayer: Player? = null
|
||||
private set
|
||||
|
||||
private fun nms(simple: String): Class<*> =
|
||||
Class.forName("net.minecraft.server.$cbVersion.$simple", true, Bukkit.getServer().javaClass.classLoader)
|
||||
private fun craft(simple: String): Class<*> =
|
||||
Class.forName("org.bukkit.craftbukkit.$cbVersion.$simple", true, Bukkit.getServer().javaClass.classLoader)
|
||||
|
||||
fun spawn(location: Location) {
|
||||
val world = location.world ?: error("Location has no world")
|
||||
val craftWorld = craft("CraftWorld").cast(world)
|
||||
val worldServer = craftWorld.javaClass.getMethod("getHandle").invoke(craftWorld)
|
||||
|
||||
val craftServer = craft("CraftServer").cast(Bukkit.getServer())
|
||||
val mcServer = craftServer.javaClass.getMethod("getServer").invoke(craftServer)
|
||||
val playerList = mcServer.javaClass.getMethod("getPlayerList").invoke(mcServer)
|
||||
|
||||
val ep = createEntityPlayer(mcServer, worldServer)
|
||||
entityPlayer = ep
|
||||
bukkitPlayer = ep.javaClass.getMethod("getBukkitEntity").invoke(ep) as Player
|
||||
|
||||
// Ensure NMS alive/dead flags are sane so Bukkit sees the player as valid.
|
||||
runCatching {
|
||||
val deadField = ep.javaClass.superclass.getDeclaredField("dead")
|
||||
deadField.isAccessible = true
|
||||
deadField.setBoolean(ep, false)
|
||||
}
|
||||
runCatching {
|
||||
val health = ep.javaClass.getMethod("setHealth", Float::class.javaPrimitiveType)
|
||||
health.invoke(ep, 20.0f)
|
||||
}
|
||||
|
||||
firePreLogin()
|
||||
setPosition(ep, location)
|
||||
setGameMode(ep)
|
||||
setVulnerability(ep, false)
|
||||
setMetaDefaults(ep)
|
||||
|
||||
val nm = setupConnection(ep, mcServer)
|
||||
|
||||
// Run the real join pipeline
|
||||
playerList.javaClass.getMethod("a", nms("NetworkManager"), nms("EntityPlayer"))
|
||||
.apply { isAccessible = true }
|
||||
.invoke(playerList, nm, ep)
|
||||
|
||||
// Clean up the duplicate UUID warning: ensure we don't re-add if PlayerList already did
|
||||
runCatching {
|
||||
val playersField = playerList.javaClass.getDeclaredField("players")
|
||||
playersField.isAccessible = true
|
||||
val list = playersField.get(playerList) as MutableList<Any>
|
||||
if (!list.contains(ep)) list.add(ep)
|
||||
}
|
||||
|
||||
// Ensure CraftServer maps see the player (for Bukkit.getPlayer)
|
||||
updateCraftMaps(craftServer, bukkitPlayer!!)
|
||||
|
||||
// Force entity into world lists (safety)
|
||||
runCatching { worldServer.javaClass.getMethod("addEntity", nms("Entity")).invoke(worldServer, ep) }
|
||||
|
||||
// Ensure chunk tracking in case join path skipped it
|
||||
runCatching {
|
||||
val pcm = worldServer.javaClass.getMethod("getPlayerChunkMap").invoke(worldServer)
|
||||
pcm.javaClass.methods.firstOrNull { it.name == "addPlayer" && it.parameterCount == 1 }?.invoke(pcm, ep)
|
||||
pcm.javaClass.methods.firstOrNull { it.name == "movePlayer" && it.parameterCount == 1 }?.invoke(pcm, ep)
|
||||
}
|
||||
|
||||
// Broadcast spawn packets (ADD_PLAYER + NamedEntitySpawn) to online players
|
||||
runCatching {
|
||||
val packetInfoClass = nms("PacketPlayOutPlayerInfo")
|
||||
val enumInfo = packetInfoClass.declaredClasses.first { it.simpleName.contains("EnumPlayerInfoAction") }
|
||||
val addPlayer = enumInfo.getField("ADD_PLAYER").get(null)
|
||||
val epArray = java.lang.reflect.Array.newInstance(nms("EntityPlayer"), 1).apply {
|
||||
java.lang.reflect.Array.set(this, 0, ep)
|
||||
}
|
||||
val infoCtor = packetInfoClass.getConstructor(enumInfo, epArray.javaClass)
|
||||
val infoPacket = infoCtor.newInstance(addPlayer, epArray)
|
||||
|
||||
val spawnPacketClass = nms("PacketPlayOutNamedEntitySpawn")
|
||||
val spawnCtor = spawnPacketClass.getConstructor(nms("EntityHuman"))
|
||||
val spawnPacket = spawnCtor.newInstance(ep)
|
||||
|
||||
val attrPacketClass = nms("PacketPlayOutUpdateAttributes")
|
||||
val attrCtor = attrPacketClass.getConstructor(nms("EntityLiving"), java.util.Collection::class.java)
|
||||
val attrMethod = ep.javaClass.methods.firstOrNull { it.name == "getAttributeInstance" }
|
||||
val attributes = java.util.ArrayList<Any>()
|
||||
// health and attack damage attributes if available
|
||||
runCatching {
|
||||
val generic = Class.forName("org.bukkit.attribute.Attribute")
|
||||
val healthEnum = generic.getField("GENERIC_MAX_HEALTH").get(null)
|
||||
val dmgEnum = generic.getField("GENERIC_ATTACK_DAMAGE").get(null)
|
||||
val attrBase = ep.javaClass.methods.firstOrNull { it.name == "getAttributeInstance" && it.parameterTypes.size == 1 }
|
||||
val healthInst = attrBase?.invoke(ep, nms("GenericAttributes").getField("MAX_HEALTH").get(null))
|
||||
val dmgInst = attrBase?.invoke(ep, nms("GenericAttributes").getField("ATTACK_DAMAGE").get(null))
|
||||
if (healthInst != null) attributes.add(healthInst)
|
||||
if (dmgInst != null) attributes.add(dmgInst)
|
||||
}
|
||||
val attrPacket = runCatching { attrCtor.newInstance(ep, attributes) }.getOrNull()
|
||||
|
||||
val heldSlotClass = nms("PacketPlayOutHeldItemSlot")
|
||||
val heldSlotPacket = runCatching { heldSlotClass.getConstructor(Int::class.javaPrimitiveType).newInstance(0) }.getOrNull()
|
||||
|
||||
val windowItemsClass = nms("PacketPlayOutWindowItems")
|
||||
val inventory = ep.javaClass.getField("inventory").get(ep)
|
||||
val getContents = inventory.javaClass.methods.firstOrNull { it.name == "getContents" && it.parameterCount == 0 }
|
||||
val contents = runCatching { getContents?.invoke(inventory) as? Array<Any> }.getOrNull()
|
||||
val windowPacket = runCatching { windowItemsClass.constructors.first().newInstance(0, listOf(*contents ?: emptyArray())) }.getOrNull()
|
||||
|
||||
val healthClass = nms("PacketPlayOutUpdateHealth")
|
||||
val healthPacket = runCatching {
|
||||
val health = ep.javaClass.getMethod("getHealth").invoke(ep) as Float
|
||||
val food = ep.javaClass.getMethod("getFoodData").invoke(ep)
|
||||
val foodLevel = food.javaClass.getMethod("getFoodLevel").invoke(food) as Int
|
||||
val saturation = food.javaClass.getMethod("getSaturationLevel").invoke(food) as Float
|
||||
healthClass.getConstructor(Float::class.javaPrimitiveType, Int::class.javaPrimitiveType, Float::class.javaPrimitiveType)
|
||||
.newInstance(health, foodLevel, saturation)
|
||||
}.getOrNull()
|
||||
|
||||
val metaClass = nms("PacketPlayOutEntityMetadata")
|
||||
val metaCtor = metaClass.getConstructor(Int::class.javaPrimitiveType, nms("DataWatcher"), Boolean::class.javaPrimitiveType)
|
||||
val dataWatcher = ep.javaClass.getMethod("getDataWatcher").invoke(ep)
|
||||
val metaPacket = metaCtor.newInstance(ep.javaClass.getMethod("getId").invoke(ep) as Int, dataWatcher, true)
|
||||
|
||||
val animClass = nms("PacketPlayOutAnimation")
|
||||
val swingPacket = runCatching { animClass.getConstructor(nms("Entity"), Int::class.javaPrimitiveType).newInstance(ep, 0) }.getOrNull()
|
||||
|
||||
Bukkit.getOnlinePlayers().forEach { viewer ->
|
||||
val handle = viewer.javaClass.getMethod("getHandle").invoke(viewer)
|
||||
val conn = handle.javaClass.getField("playerConnection").get(handle)
|
||||
val send = conn.javaClass.methods.first { it.name == "sendPacket" && it.parameterTypes.size == 1 }
|
||||
send.invoke(conn, infoPacket)
|
||||
send.invoke(conn, spawnPacket)
|
||||
if (attrPacket != null) send.invoke(conn, attrPacket)
|
||||
if (heldSlotPacket != null) send.invoke(conn, heldSlotPacket)
|
||||
if (windowPacket != null) send.invoke(conn, windowPacket)
|
||||
if (healthPacket != null) send.invoke(conn, healthPacket)
|
||||
send.invoke(conn, metaPacket)
|
||||
if (swingPacket != null) send.invoke(conn, swingPacket)
|
||||
}
|
||||
}
|
||||
|
||||
// Tick task to keep status/effects progressing
|
||||
Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, Runnable {
|
||||
runCatching {
|
||||
// playerTick is "m" in 1.9; fall back to "n" if obf differs
|
||||
val tick = ep.javaClass.methods.firstOrNull { it.name == "m" && it.parameterCount == 0 }
|
||||
?: ep.javaClass.methods.firstOrNull { it.name == "n" && it.parameterCount == 0 }
|
||||
?: ep.javaClass.methods.firstOrNull { it.name == "playerTick" && it.parameterCount == 0 }
|
||||
tick?.invoke(ep)
|
||||
// Keep entity alive/valid flags cleared so Bukkit reports the player as valid
|
||||
runCatching {
|
||||
val deadField = ep.javaClass.superclass.getDeclaredField("dead")
|
||||
deadField.isAccessible = true
|
||||
deadField.setBoolean(ep, false)
|
||||
}
|
||||
runCatching {
|
||||
val craftEntity = Class.forName("org.bukkit.craftbukkit.$cbVersion.entity.CraftEntity")
|
||||
val validField = craftEntity.getDeclaredField("valid")
|
||||
validField.isAccessible = true
|
||||
validField.setBoolean(bukkitPlayer, true)
|
||||
}
|
||||
}
|
||||
// keep chunk tracking fresh
|
||||
runCatching {
|
||||
val worldServer = ep.javaClass.getMethod("getWorld").invoke(ep)
|
||||
val pcm = worldServer.javaClass.getMethod("getPlayerChunkMap").invoke(worldServer)
|
||||
pcm.javaClass.methods.firstOrNull { it.name == "movePlayer" && it.parameterCount == 1 }?.invoke(pcm, ep)
|
||||
}
|
||||
// Apply queued effects/fire ticks
|
||||
runCatching {
|
||||
// Force entity base tick for fire/water checks
|
||||
ep.javaClass.methods.firstOrNull { it.name == "ae" && it.parameterCount == 0 } // baseTick in 1.9 obf
|
||||
?.invoke(ep)
|
||||
}
|
||||
// Ensure water extinguishes burning for fake players on legacy
|
||||
runCatching {
|
||||
val bp = bukkitPlayer
|
||||
if (bp != null && bp.fireTicks > 0 && bp.location.block.isLiquid) {
|
||||
bp.fireTicks = 0
|
||||
}
|
||||
}
|
||||
}, 1L, 1L)
|
||||
}
|
||||
|
||||
fun removePlayer() {
|
||||
val ep = entityPlayer ?: return
|
||||
val bp = bukkitPlayer ?: return
|
||||
val craftServer = craft("CraftServer").cast(Bukkit.getServer())
|
||||
val mcServer = craftServer.javaClass.getMethod("getServer").invoke(craftServer)
|
||||
val playerList = mcServer.javaClass.getMethod("getPlayerList").invoke(mcServer)
|
||||
|
||||
bp.kickPlayer("§e$name left the game")
|
||||
runCatching {
|
||||
playerList.javaClass.getMethod("disconnect", nms("EntityPlayer")).invoke(playerList, ep)
|
||||
}.onFailure {
|
||||
runCatching { playerList.javaClass.getMethod("remove", nms("EntityPlayer")).invoke(playerList, ep) }
|
||||
}
|
||||
runCatching {
|
||||
val pcm = getPlayerChunkMap(ep)
|
||||
pcm?.javaClass?.methods?.firstOrNull { it.name == "removePlayer" && it.parameterCount == 1 }?.invoke(pcm, ep)
|
||||
}
|
||||
}
|
||||
|
||||
fun teleport(location: Location): Boolean {
|
||||
val ep = entityPlayer ?: return false
|
||||
val bp = bukkitPlayer ?: return false
|
||||
val targetWorld = location.world ?: return false
|
||||
|
||||
if (bp.world.uid == targetWorld.uid) {
|
||||
return bp.teleport(location)
|
||||
}
|
||||
|
||||
val craftWorld = craft("CraftWorld").cast(targetWorld)
|
||||
val targetWorldServer = craftWorld.javaClass.getMethod("getHandle").invoke(craftWorld)
|
||||
val dimension = targetWorldServer.javaClass.getField("dimension").getInt(targetWorldServer)
|
||||
|
||||
val craftServer = craft("CraftServer").cast(Bukkit.getServer())
|
||||
val mcServer = craftServer.javaClass.getMethod("getServer").invoke(craftServer)
|
||||
val playerList = mcServer.javaClass.getMethod("getPlayerList").invoke(mcServer)
|
||||
|
||||
val moveMethod =
|
||||
playerList.javaClass.methods.firstOrNull {
|
||||
it.name == "moveToWorld" &&
|
||||
it.parameterTypes.size == 5 &&
|
||||
it.parameterTypes[0].isAssignableFrom(ep.javaClass) &&
|
||||
it.parameterTypes[1] == Int::class.javaPrimitiveType &&
|
||||
it.parameterTypes[2] == Boolean::class.javaPrimitiveType &&
|
||||
it.parameterTypes[3].isAssignableFrom(Location::class.java) &&
|
||||
it.parameterTypes[4] == Boolean::class.javaPrimitiveType
|
||||
} ?: return false
|
||||
|
||||
val moved = moveMethod.invoke(playerList, ep, dimension, false, location, true) ?: return false
|
||||
entityPlayer = moved
|
||||
bukkitPlayer = moved.javaClass.getMethod("getBukkitEntity").invoke(moved) as Player
|
||||
updateCraftMaps(craftServer, bukkitPlayer!!)
|
||||
return bukkitPlayer!!.world.uid == targetWorld.uid
|
||||
}
|
||||
|
||||
fun getConnection(serverPlayer: Any): Any {
|
||||
val field = serverPlayer.javaClass.getField("playerConnection")
|
||||
return field.get(serverPlayer)
|
||||
}
|
||||
|
||||
private fun createEntityPlayer(mcServer: Any, worldServer: Any): Any {
|
||||
val epClass = nms("EntityPlayer")
|
||||
val pimClass = nms("PlayerInteractManager")
|
||||
// PlayerInteractManager(World | WorldServer)
|
||||
val pimCtor = pimClass.constructors.firstOrNull { ctor ->
|
||||
ctor.parameterTypes.size == 1 && ctor.parameterTypes[0].isAssignableFrom(worldServer.javaClass)
|
||||
} ?: pimClass.constructors.first()
|
||||
val pim = pimCtor.newInstance(worldServer)
|
||||
val gp = GameProfile(uuid, name)
|
||||
|
||||
val ctor = epClass.constructors.firstOrNull { ctor ->
|
||||
val p = ctor.parameterTypes
|
||||
p.size == 4 &&
|
||||
p[0].isAssignableFrom(mcServer.javaClass) &&
|
||||
p[1].isAssignableFrom(worldServer.javaClass) &&
|
||||
p[2].isAssignableFrom(GameProfile::class.java) &&
|
||||
p[3].isAssignableFrom(pimClass)
|
||||
} ?: epClass.constructors.first()
|
||||
|
||||
return ctor.newInstance(mcServer, worldServer, gp, pim)
|
||||
}
|
||||
|
||||
private fun setupConnection(ep: Any, mcServer: Any): Any {
|
||||
val nmClass = nms("NetworkManager")
|
||||
val dirClass = nms("EnumProtocolDirection")
|
||||
val clientbound = dirClass.getField("CLIENTBOUND").get(null)
|
||||
val nm = nmClass.getConstructor(dirClass).newInstance(clientbound)
|
||||
// Dummy channel with predictable address
|
||||
val remote = java.net.InetSocketAddress("127.0.0.1", 25565)
|
||||
val channel = EmbeddedChannel(ChannelInboundHandlerAdapter())
|
||||
val pipeline = channel.pipeline()
|
||||
if (pipeline.get("decoder") == null) {
|
||||
pipeline.addLast("decoder", ChannelInboundHandlerAdapter())
|
||||
}
|
||||
if (pipeline.get("encoder") == null) {
|
||||
pipeline.addLast("encoder", ChannelOutboundHandlerAdapter())
|
||||
}
|
||||
nmClass.getField("channel").set(nm, channel)
|
||||
runCatching { nmClass.getField("socketAddress").set(nm, remote) }
|
||||
|
||||
val pcClass = nms("PlayerConnection")
|
||||
val pc = pcClass.getConstructor(nms("MinecraftServer"), nmClass, nms("EntityPlayer"))
|
||||
.newInstance(mcServer, nm, ep)
|
||||
ep.javaClass.getField("playerConnection").set(ep, pc)
|
||||
runCatching {
|
||||
val setListener = nmClass.methods.firstOrNull { it.name == "setPacketListener" && it.parameterCount == 1 }
|
||||
setListener?.invoke(nm, pc)
|
||||
}
|
||||
runCatching {
|
||||
val enumProtocol = nms("EnumProtocol")
|
||||
val play = enumProtocol.getField("PLAY").get(null)
|
||||
nmClass.methods.firstOrNull { it.name == "a" && it.parameterTypes.singleOrNull() == enumProtocol }
|
||||
?.invoke(nm, play)
|
||||
}
|
||||
runCatching {
|
||||
nmClass.fields.firstOrNull { it.name == "isPending" }?.setBoolean(nm, false)
|
||||
}
|
||||
return nm
|
||||
}
|
||||
|
||||
private fun setPosition(ep: Any, loc: Location) {
|
||||
ep.javaClass.getMethod(
|
||||
"setPositionRotation",
|
||||
Double::class.javaPrimitiveType,
|
||||
Double::class.javaPrimitiveType,
|
||||
Double::class.javaPrimitiveType,
|
||||
Float::class.javaPrimitiveType,
|
||||
Float::class.javaPrimitiveType
|
||||
).invoke(ep, loc.x, loc.y, loc.z, loc.yaw, loc.pitch)
|
||||
}
|
||||
|
||||
private fun setGameMode(ep: Any) {
|
||||
val gmName = when (Bukkit.getDefaultGameMode()) {
|
||||
GameMode.CREATIVE -> "CREATIVE"
|
||||
GameMode.ADVENTURE -> "ADVENTURE"
|
||||
GameMode.SPECTATOR -> "SPECTATOR"
|
||||
else -> "SURVIVAL"
|
||||
}
|
||||
val enumGMClass = nms("WorldSettings\$EnumGamemode")
|
||||
val enumGM = enumGMClass.getField(gmName).get(null)
|
||||
val pim = ep.javaClass.getField("playerInteractManager").get(ep)
|
||||
// prefer setGameMode / b(EnumGamemode)
|
||||
val method = pim.javaClass.methods.firstOrNull { it.name in listOf("setGameMode", "b") && it.parameterTypes.size == 1 }
|
||||
?: pim.javaClass.getMethod("b", enumGMClass)
|
||||
method.isAccessible = true
|
||||
method.invoke(pim, enumGM)
|
||||
}
|
||||
|
||||
private fun setVulnerability(ep: Any, invulnerable: Boolean) {
|
||||
runCatching { ep.javaClass.getField("invulnerableTicks").setInt(ep, if (invulnerable) 20 else 0) }
|
||||
runCatching { ep.javaClass.getField("noDamageTicks").setInt(ep, if (invulnerable) 20 else 0) }
|
||||
runCatching {
|
||||
val abilities = ep.javaClass.getField("abilities").get(ep)
|
||||
val flags = mapOf(
|
||||
"isInvulnerable" to invulnerable,
|
||||
"isFlying" to false,
|
||||
"mayfly" to false,
|
||||
"canInstantlyBuild" to false
|
||||
)
|
||||
flags.forEach { (k, v) ->
|
||||
runCatching { abilities.javaClass.getField(k).setBoolean(abilities, v) }
|
||||
}
|
||||
ep.javaClass.getMethod("updateAbilities").invoke(ep)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setMetaDefaults(ep: Any) {
|
||||
runCatching {
|
||||
val dw = ep.javaClass.getMethod("getDataWatcher").invoke(ep)
|
||||
val serializerRegistry = nms("DataWatcherRegistry")
|
||||
val byteSerializer = serializerRegistry.getField("a").get(null)
|
||||
val floatSerializer = serializerRegistry.getField("c").get(null)
|
||||
|
||||
val dwoClass = nms("DataWatcherObject")
|
||||
val dwoCtor = dwoClass.getConstructor(Int::class.javaPrimitiveType, nms("DataWatcherSerializer"))
|
||||
|
||||
val set = dw.javaClass.methods.first { it.name == "set" && it.parameterTypes.size == 2 }
|
||||
|
||||
// flag byte (index 0) -> 0
|
||||
val flag0 = dwoCtor.newInstance(0, byteSerializer)
|
||||
set.invoke(dw, flag0, 0.toByte())
|
||||
|
||||
// health (index 6) -> 20f
|
||||
val healthObj = dwoCtor.newInstance(6, floatSerializer)
|
||||
set.invoke(dw, healthObj, 20.0f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun firePreLogin() {
|
||||
val addr = InetAddress.getLoopbackAddress()
|
||||
val async = AsyncPlayerPreLoginEvent(name, addr, uuid)
|
||||
val sync = PlayerPreLoginEvent(name, addr, uuid)
|
||||
Thread { Bukkit.getPluginManager().callEvent(async) }.start()
|
||||
Bukkit.getPluginManager().callEvent(sync)
|
||||
}
|
||||
|
||||
private fun getChunkProvider(ep: Any): Any? = runCatching {
|
||||
val worldServer = ep.javaClass.getMethod("getWorld").invoke(ep)
|
||||
worldServer.javaClass.getMethod("getChunkProviderServer").invoke(worldServer)
|
||||
}.getOrNull()
|
||||
|
||||
private fun getPlayerChunkMap(ep: Any): Any? = runCatching {
|
||||
val worldServer = ep.javaClass.getMethod("getWorld").invoke(ep)
|
||||
worldServer.javaClass.getMethod("getPlayerChunkMap").invoke(worldServer)
|
||||
}.getOrNull()
|
||||
|
||||
private fun updateCraftMaps(craftServer: Any, player: Player) {
|
||||
runCatching {
|
||||
val playersField = craftServer.javaClass.getDeclaredField("players")
|
||||
playersField.isAccessible = true
|
||||
val map = playersField.get(craftServer) as MutableMap<String, Player>
|
||||
map[player.name.lowercase(Locale.getDefault())] = player
|
||||
}
|
||||
runCatching {
|
||||
val uuidField = craftServer.javaClass.getDeclaredField("playersByUUID")
|
||||
uuidField.isAccessible = true
|
||||
val map = uuidField.get(craftServer) as MutableMap<UUID, Player>
|
||||
map[player.uniqueId] = player
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import com.cryptomorin.xseries.XAttribute
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldNotBeEmpty
|
||||
import io.kotest.matchers.doubles.plusOrMinus
|
||||
import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.CompatibilityCapabilities
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.Config
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage
|
||||
import kotlinx.coroutines.delay
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.Difficulty
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.entity.AbstractArrow
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.entity.Skeleton
|
||||
import org.bukkit.entity.Trident
|
||||
import org.bukkit.entity.Zombie
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.EventPriority
|
||||
import org.bukkit.event.HandlerList
|
||||
import org.bukkit.event.Listener
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import org.bukkit.scheduler.BukkitRunnable
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
// Instruments mob->player melee cadence: a forced swing every tick must never damage the
|
||||
// player more often than the vanilla half-rule window (maximumNoDamageTicks / 2) allows.
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class MobInvulnerabilityIntegrationTest :
|
||||
FunSpec({
|
||||
val plugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
extensions(MainThreadDispatcherExtension(plugin))
|
||||
|
||||
fun <T> runSync(action: () -> T): T = if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit.getScheduler().callSyncMethod(plugin, Callable { action() }).get()
|
||||
}
|
||||
|
||||
suspend fun delayTicks(ticks: Long) {
|
||||
delay(ticks * 50L)
|
||||
}
|
||||
|
||||
data class HitRecord(
|
||||
val tick: Int,
|
||||
val damage: Double,
|
||||
val finalDamage: Double,
|
||||
val cancelled: Boolean,
|
||||
val noDamageTicks: Int
|
||||
)
|
||||
|
||||
suspend fun measureHitCadence(label: String, armed: Boolean): Pair<Int, List<HitRecord>> {
|
||||
val records = mutableListOf<HitRecord>()
|
||||
lateinit var victimFake: FakePlayer
|
||||
lateinit var victim: Player
|
||||
lateinit var zombie: Zombie
|
||||
var tick = 0
|
||||
// Long enough for slow-accumulator anticheat heuristics (e.g. NCP godmode) to trigger
|
||||
val totalTicks = 200
|
||||
|
||||
val listener = object : Listener {
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
|
||||
fun onDamage(event: EntityDamageByEntityEvent) {
|
||||
if (event.entity.uniqueId != victim.uniqueId) return
|
||||
if (event.damager.uniqueId != zombie.uniqueId) return
|
||||
records.add(
|
||||
HitRecord(tick, event.damage, event.finalDamage, event.isCancelled, victim.noDamageTicks)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
// Offset away from the origin used by the other combat specs
|
||||
val base = Location(world, 20.0, 100.0, 20.0)
|
||||
victimFake = FakePlayer(plugin)
|
||||
victimFake.spawn(base)
|
||||
victim = checkNotNull(Bukkit.getPlayer(victimFake.uuid))
|
||||
victim.gameMode = GameMode.SURVIVAL
|
||||
victim.isInvulnerable = false
|
||||
victim.inventory.clear()
|
||||
victim.inventory.armorContents = arrayOfNulls(4)
|
||||
victim.health = victim.maxHealth
|
||||
victim.noDamageTicks = 0
|
||||
victim.lastDamage = 0.0
|
||||
|
||||
zombie = world.spawn(base.clone().add(1.1, 0.0, 0.0), Zombie::class.java)
|
||||
zombie.setAI(false)
|
||||
zombie.isSilent = true
|
||||
zombie.equipment?.setItemInMainHand(
|
||||
if (armed) ItemStack(Material.IRON_SWORD) else ItemStack(Material.AIR)
|
||||
)
|
||||
|
||||
Bukkit.getPluginManager().registerEvents(listener, plugin)
|
||||
}
|
||||
|
||||
val maxTicks = runSync { victim.maximumNoDamageTicks }
|
||||
plugin.logger.info("MobInvuln[$label]: victim maximumNoDamageTicks=$maxTicks")
|
||||
|
||||
runSync {
|
||||
object : BukkitRunnable() {
|
||||
override fun run() {
|
||||
tick += 1
|
||||
if (tick > totalTicks) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
// Top the victim up between swings; healing does not touch invulnerability state
|
||||
if (victim.health < 10.0) victim.health = victim.maxHealth
|
||||
zombie.fireTicks = 0
|
||||
// Bukkit damage() drives the same NMS hurt() pipeline (invulnerability gate
|
||||
// included) without depending on obfuscated mob attack method names
|
||||
victim.damage(3.0, zombie)
|
||||
}
|
||||
}.runTaskTimer(plugin, 1L, 1L)
|
||||
}
|
||||
|
||||
while (tick <= totalTicks) delayTicks(5)
|
||||
delayTicks(2)
|
||||
|
||||
records.forEach {
|
||||
plugin.logger.info(
|
||||
"MobInvuln[$label]: tick=${it.tick} dmg=${it.damage} final=${it.finalDamage}" +
|
||||
" cancelled=${it.cancelled} ndt=${it.noDamageTicks}"
|
||||
)
|
||||
}
|
||||
return maxTicks to records
|
||||
} finally {
|
||||
HandlerList.unregisterAll(listener)
|
||||
runSync {
|
||||
victimFake.removePlayer()
|
||||
zombie.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun landedTicks(records: List<HitRecord>): List<Int> =
|
||||
records.filter { !it.cancelled && it.finalDamage > 0.0 }.map { it.tick }
|
||||
|
||||
suspend fun assertVanillaCadence(label: String, armed: Boolean) {
|
||||
val (maxTicks, records) = measureHitCadence(label, armed)
|
||||
val landed = landedTicks(records)
|
||||
plugin.logger.info(
|
||||
"MobInvuln[$label]: events=${records.size} landedTicks=$landed window=${maxTicks / 2}"
|
||||
)
|
||||
landed.shouldNotBeEmpty()
|
||||
// A swing lands every maxTicks/2 at the earliest; anything closer is damage leaking
|
||||
// through the invulnerability window
|
||||
landed.zipWithNext { a, b -> b - a }.forEach { gap ->
|
||||
gap.shouldBeGreaterThanOrEqual(maxTicks / 2)
|
||||
}
|
||||
}
|
||||
|
||||
test("unarmed zombie swinging every tick damages a player at most every half-window") {
|
||||
assertVanillaCadence("unarmed", armed = false)
|
||||
}
|
||||
|
||||
test("sword-wielding zombie swinging every tick damages a player at most every half-window") {
|
||||
assertVanillaCadence("iron-sword", armed = true)
|
||||
}
|
||||
|
||||
test("mob arrows cannot pierce the invulnerability window opened by a melee hit") {
|
||||
data class ArrowHit(val tick: Int, val finalDamage: Double, val cancelled: Boolean, val noDamageTicks: Int)
|
||||
|
||||
val arrowHits = mutableListOf<ArrowHit>()
|
||||
var meleeLandedTick = -1
|
||||
lateinit var victimFake: FakePlayer
|
||||
lateinit var victim: Player
|
||||
lateinit var zombie: Zombie
|
||||
lateinit var skeleton: Skeleton
|
||||
var tick = 0
|
||||
val totalTicks = 12
|
||||
|
||||
val listener = object : Listener {
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
|
||||
fun onDamage(event: EntityDamageByEntityEvent) {
|
||||
if (event.entity.uniqueId != victim.uniqueId) return
|
||||
when (event.damager) {
|
||||
is Zombie -> if (!event.isCancelled && event.finalDamage > 0.0 && meleeLandedTick < 0) {
|
||||
meleeLandedTick = tick
|
||||
}
|
||||
|
||||
is AbstractArrow -> arrowHits.add(
|
||||
ArrowHit(tick, event.finalDamage, event.isCancelled, victim.noDamageTicks)
|
||||
)
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val base = Location(world, 40.0, 100.0, 40.0)
|
||||
victimFake = FakePlayer(plugin)
|
||||
victimFake.spawn(base)
|
||||
victim = checkNotNull(Bukkit.getPlayer(victimFake.uuid))
|
||||
victim.gameMode = GameMode.SURVIVAL
|
||||
victim.isInvulnerable = false
|
||||
victim.inventory.clear()
|
||||
victim.inventory.armorContents = arrayOfNulls(4)
|
||||
victim.health = victim.maxHealth
|
||||
victim.noDamageTicks = 0
|
||||
victim.lastDamage = 0.0
|
||||
|
||||
zombie = world.spawn(base.clone().add(1.1, 0.0, 0.0), Zombie::class.java)
|
||||
zombie.setAI(false)
|
||||
zombie.isSilent = true
|
||||
skeleton = world.spawn(base.clone().add(-2.0, 0.0, 0.0), Skeleton::class.java)
|
||||
skeleton.setAI(false)
|
||||
skeleton.isSilent = true
|
||||
|
||||
Bukkit.getPluginManager().registerEvents(listener, plugin)
|
||||
|
||||
object : BukkitRunnable() {
|
||||
override fun run() {
|
||||
tick += 1
|
||||
if (tick > totalTicks) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
if (victim.health < 10.0) victim.health = victim.maxHealth
|
||||
if (tick == 1) {
|
||||
// Melee hit opens the 20-tick window (and OCM zeroes lastHurt next tick)
|
||||
victim.damage(3.0, zombie)
|
||||
} else if (tick in 4..10 && tick % 2 == 0) {
|
||||
// Point-blank arrow aimed at the victim's chest, well inside the window
|
||||
val eye = victim.location.clone().add(0.0, 1.2, 0.0)
|
||||
val from = eye.clone().add(0.9, 0.0, 0.0)
|
||||
val direction = eye.toVector().subtract(from.toVector()).normalize()
|
||||
val arrow = victim.world.spawnArrow(from, direction, 1.0f, 0.0f)
|
||||
arrow.shooter = skeleton
|
||||
}
|
||||
}
|
||||
}.runTaskTimer(plugin, 1L, 1L)
|
||||
}
|
||||
|
||||
while (tick <= totalTicks) delayTicks(5)
|
||||
delayTicks(2)
|
||||
|
||||
arrowHits.forEach {
|
||||
plugin.logger.info(
|
||||
"MobInvuln[arrow-pierce]: tick=${it.tick} final=${it.finalDamage}" +
|
||||
" cancelled=${it.cancelled} ndt=${it.noDamageTicks}"
|
||||
)
|
||||
}
|
||||
plugin.logger.info(
|
||||
"MobInvuln[arrow-pierce]: meleeLandedTick=$meleeLandedTick arrowEvents=${arrowHits.size}"
|
||||
)
|
||||
|
||||
meleeLandedTick shouldBe 1
|
||||
val halfWindow = runSync { victim.maximumNoDamageTicks } / 2
|
||||
// Vanilla: a 2-damage arrow with noDamageTicks still above the half-window after
|
||||
// a 3-damage melee hit is fully rejected (amount <= lastHurt); landing one is a pierce.
|
||||
// At or below the half-window a full hit is legal, so judge by event-time ticks.
|
||||
val pierced = arrowHits.filter {
|
||||
!it.cancelled && it.finalDamage > 0.0 && it.noDamageTicks > halfWindow
|
||||
}
|
||||
pierced shouldBe emptyList()
|
||||
// The gate must actually have engaged on the in-window arrows
|
||||
arrowHits.count { it.cancelled && it.noDamageTicks > halfWindow } shouldBeGreaterThanOrEqual 2
|
||||
} finally {
|
||||
HandlerList.unregisterAll(listener)
|
||||
runSync {
|
||||
victimFake.removePlayer()
|
||||
zombie.remove()
|
||||
skeleton.remove()
|
||||
victim.world.entities.filterIsInstance<AbstractArrow>().forEach { it.remove() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("in-window projectile overdamage deals the vanilla difference after environmental damage") {
|
||||
data class ArrowHit(
|
||||
val damage: Double,
|
||||
val finalDamage: Double,
|
||||
val cancelled: Boolean,
|
||||
val noDamageTicks: Int,
|
||||
val lastDamage: Double
|
||||
)
|
||||
|
||||
val mainHits = mutableListOf<ArrowHit>()
|
||||
val controlHits = mutableListOf<ArrowHit>()
|
||||
lateinit var victimFake: FakePlayer
|
||||
lateinit var victim: Player
|
||||
lateinit var controlFake: FakePlayer
|
||||
lateinit var control: Player
|
||||
lateinit var skeleton: Skeleton
|
||||
var tick = 0
|
||||
val totalTicks = 8
|
||||
|
||||
fun record(target: Player, sink: MutableList<ArrowHit>, event: EntityDamageByEntityEvent) {
|
||||
sink.add(
|
||||
ArrowHit(
|
||||
event.damage,
|
||||
event.finalDamage,
|
||||
event.isCancelled,
|
||||
target.noDamageTicks,
|
||||
target.lastDamage
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val listener = object : Listener {
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
|
||||
fun onDamage(event: EntityDamageByEntityEvent) {
|
||||
if (event.damager !is AbstractArrow) return
|
||||
when (event.entity.uniqueId) {
|
||||
victim.uniqueId -> record(victim, mainHits, event)
|
||||
control.uniqueId -> record(control, controlHits, event)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setupTarget(player: Player) {
|
||||
player.gameMode = GameMode.SURVIVAL
|
||||
player.isInvulnerable = false
|
||||
player.inventory.clear()
|
||||
player.inventory.armorContents = arrayOfNulls(4)
|
||||
player.health = player.maxHealth
|
||||
player.noDamageTicks = 0
|
||||
player.lastDamage = 0.0
|
||||
}
|
||||
|
||||
fun fireArrowAt(target: Player) {
|
||||
val eye = target.location.clone().add(0.0, 1.2, 0.0)
|
||||
val from = eye.clone().add(0.9, 0.0, 0.0)
|
||||
val direction = eye.toVector().subtract(from.toVector()).normalize()
|
||||
val arrow = target.world.spawnArrow(from, direction, 1.0f, 0.0f)
|
||||
arrow.shooter = skeleton
|
||||
arrow.damage = 6.0
|
||||
}
|
||||
|
||||
try {
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val base = Location(world, 50.0, 100.0, 50.0)
|
||||
victimFake = FakePlayer(plugin)
|
||||
victimFake.spawn(base)
|
||||
victim = checkNotNull(Bukkit.getPlayer(victimFake.uuid))
|
||||
setupTarget(victim)
|
||||
|
||||
// Identical setup with no prior damage: its arrow event reveals the true NMS amount
|
||||
controlFake = FakePlayer(plugin)
|
||||
controlFake.spawn(base.clone().add(0.0, 0.0, 8.0))
|
||||
control = checkNotNull(Bukkit.getPlayer(controlFake.uuid))
|
||||
setupTarget(control)
|
||||
|
||||
skeleton = world.spawn(base.clone().add(-2.0, 0.0, 0.0), Skeleton::class.java)
|
||||
skeleton.setAI(false)
|
||||
skeleton.isSilent = true
|
||||
|
||||
Bukkit.getPluginManager().registerEvents(listener, plugin)
|
||||
|
||||
object : BukkitRunnable() {
|
||||
override fun run() {
|
||||
tick += 1
|
||||
if (tick > totalTicks) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
if (tick == 1) {
|
||||
// Environmental hit opens the window and leaves NMS lastHurt at 2.0
|
||||
// (the MONITOR zeroing only applies to entity damage)
|
||||
victim.damage(2.0)
|
||||
} else if (tick == 3) {
|
||||
fireArrowAt(victim)
|
||||
fireArrowAt(control)
|
||||
}
|
||||
}
|
||||
}.runTaskTimer(plugin, 1L, 1L)
|
||||
}
|
||||
|
||||
while (tick <= totalTicks) delayTicks(5)
|
||||
delayTicks(2)
|
||||
|
||||
(mainHits.map { "main" to it } + controlHits.map { "control" to it }).forEach { (tag, hit) ->
|
||||
plugin.logger.info(
|
||||
"MobInvuln[env-overdamage]: $tag dmg=${hit.damage} final=${hit.finalDamage}" +
|
||||
" cancelled=${hit.cancelled} ndt=${hit.noDamageTicks} lastDamage=${hit.lastDamage}"
|
||||
)
|
||||
}
|
||||
|
||||
controlHits.size shouldBe 1
|
||||
mainHits.size shouldBe 1
|
||||
val controlHit = controlHits.single()
|
||||
val mainHit = mainHits.single()
|
||||
controlHit.cancelled shouldBe false
|
||||
mainHit.cancelled shouldBe false
|
||||
// The control victim has no invulnerability window, so its event damage is the raw
|
||||
// NMS arrow amount. Vanilla deals amount minus the 2.0 environmental baseline inside
|
||||
// the window; a double-subtracting gate loses a further 2.0.
|
||||
mainHit.finalDamage shouldBe (controlHit.damage - 2.0)
|
||||
} finally {
|
||||
HandlerList.unregisterAll(listener)
|
||||
runSync {
|
||||
victimFake.removePlayer()
|
||||
controlFake.removePlayer()
|
||||
skeleton.remove()
|
||||
victim.world.entities.filterIsInstance<AbstractArrow>().forEach { it.remove() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("in-window thrown trident deals the configured value minus the baseline") {
|
||||
if (!CompatibilityCapabilities.isBukkitClassAvailable("org.bukkit.entity.Trident")) {
|
||||
plugin.logger.info("MobInvuln[trident-window]: no Trident class, skipping")
|
||||
return@test
|
||||
}
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
val configured = 15.0
|
||||
val baseline = 2.0
|
||||
val oldTrident = ocm.config.get("old-tool-damage.damages.TRIDENT_THROWN")
|
||||
lateinit var victimFake: FakePlayer
|
||||
lateinit var victim: Player
|
||||
var landed = -1.0
|
||||
var tick = 0
|
||||
|
||||
val listener = object : Listener {
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
fun onDamage(event: EntityDamageByEntityEvent) {
|
||||
if (event.entity.uniqueId == victim.uniqueId && event.damager is Trident) {
|
||||
landed = event.finalDamage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runSync {
|
||||
ocm.config.set("old-tool-damage.damages.TRIDENT_THROWN", configured)
|
||||
ocm.saveConfig()
|
||||
Config.reload()
|
||||
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val base = Location(world, 70.0, 100.0, 70.0)
|
||||
victimFake = FakePlayer(plugin)
|
||||
victimFake.spawn(base)
|
||||
victim = checkNotNull(Bukkit.getPlayer(victimFake.uuid))
|
||||
victim.gameMode = GameMode.SURVIVAL
|
||||
victim.isInvulnerable = false
|
||||
victim.inventory.clear()
|
||||
victim.inventory.armorContents = arrayOfNulls(4)
|
||||
victim.health = victim.maxHealth
|
||||
victim.noDamageTicks = 0
|
||||
victim.lastDamage = 0.0
|
||||
|
||||
Bukkit.getPluginManager().registerEvents(listener, plugin)
|
||||
|
||||
object : BukkitRunnable() {
|
||||
override fun run() {
|
||||
tick += 1
|
||||
if (tick > 8) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
if (tick == 1) {
|
||||
// Non-entity damage opens the window and leaves a real NMS baseline
|
||||
victim.damage(baseline)
|
||||
} else if (tick == 3) {
|
||||
val eye = victim.location.clone().add(0.0, 1.2, 0.0)
|
||||
val from = eye.clone().add(0.9, 0.0, 0.0)
|
||||
val dir = eye.toVector().subtract(from.toVector()).normalize()
|
||||
val trident = world.spawn(from, Trident::class.java)
|
||||
trident.velocity = dir.multiply(0.6)
|
||||
}
|
||||
}
|
||||
}.runTaskTimer(plugin, 1L, 1L)
|
||||
}
|
||||
|
||||
while (tick <= 8) delayTicks(4)
|
||||
delayTicks(3)
|
||||
|
||||
plugin.logger.info("MobInvuln[trident-window]: landed=$landed expected=${configured - baseline}")
|
||||
// The configured value is a full hit, so the window must subtract the baseline once.
|
||||
// Adding the baseline back on top of a config constant would deal the full value.
|
||||
landed shouldBe ((configured - baseline) plusOrMinus 0.001)
|
||||
} finally {
|
||||
HandlerList.unregisterAll(listener)
|
||||
runSync {
|
||||
ocm.config.set("old-tool-damage.damages.TRIDENT_THROWN", oldTrident)
|
||||
ocm.saveConfig()
|
||||
Config.reload()
|
||||
victimFake.removePlayer()
|
||||
victim.world.entities.filterIsInstance<AbstractArrow>().forEach { it.remove() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("armed mob weapon delta scales with hard difficulty") {
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
lateinit var victimFake: FakePlayer
|
||||
lateinit var victim: Player
|
||||
lateinit var zombie: Zombie
|
||||
var landed = -1.0
|
||||
val world = runSync { checkNotNull(Bukkit.getWorld("world")) }
|
||||
val oldDifficulty = runSync { world.difficulty }
|
||||
val configuredIronAxe = ocm.config.getDouble("old-tool-damage.damages.IRON_AXE", -1.0)
|
||||
|
||||
val listener = object : Listener {
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
fun onDamage(event: EntityDamageByEntityEvent) {
|
||||
if (event.entity.uniqueId == victim.uniqueId && event.damager is Zombie) {
|
||||
landed = event.finalDamage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (configuredIronAxe <= 0.0) {
|
||||
plugin.logger.info("MobInvuln[hard-delta]: no IRON_AXE damage configured, skipping")
|
||||
return@test
|
||||
}
|
||||
runSync {
|
||||
world.difficulty = Difficulty.HARD
|
||||
victimFake = FakePlayer(plugin)
|
||||
victimFake.spawn(Location(world, 60.0, 100.0, 60.0))
|
||||
victim = checkNotNull(Bukkit.getPlayer(victimFake.uuid))
|
||||
victim.gameMode = GameMode.SURVIVAL
|
||||
victim.isInvulnerable = false
|
||||
victim.inventory.clear()
|
||||
victim.inventory.armorContents = arrayOfNulls(4)
|
||||
victim.health = victim.maxHealth
|
||||
victim.noDamageTicks = 0
|
||||
victim.lastDamage = 0.0
|
||||
|
||||
zombie = world.spawn(Location(world, 61.0, 100.0, 60.0), Zombie::class.java)
|
||||
zombie.setAI(false)
|
||||
zombie.isSilent = true
|
||||
zombie.equipment?.setItemInMainHand(ItemStack(Material.IRON_AXE))
|
||||
|
||||
Bukkit.getPluginManager().registerEvents(listener, plugin)
|
||||
}
|
||||
delayTicks(2)
|
||||
|
||||
val attribute = runSync {
|
||||
val attackDamage = checkNotNull(XAttribute.ATTACK_DAMAGE.get())
|
||||
checkNotNull(zombie.getAttribute(attackDamage)).value
|
||||
}
|
||||
// Bukkit damage() drives Player.hurt, so hard difficulty scales this by 1.5 first
|
||||
runSync { victim.damage(attribute, zombie) }
|
||||
delayTicks(3)
|
||||
|
||||
val vanillaItem = NewWeaponDamage.getDamageOrNull(Material.IRON_AXE)?.toDouble()
|
||||
val delta = configuredIronAxe - checkNotNull(vanillaItem)
|
||||
val expected = (attribute + delta) * 1.5
|
||||
plugin.logger.info(
|
||||
"MobInvuln[hard-delta]: attribute=$attribute delta=$delta landed=$landed expected=$expected"
|
||||
)
|
||||
// The delta must be scaled with the hit, so the result equals the 1.8 value times 1.5
|
||||
landed shouldBe (expected plusOrMinus 0.001)
|
||||
} finally {
|
||||
HandlerList.unregisterAll(listener)
|
||||
runSync {
|
||||
world.difficulty = oldDifficulty
|
||||
victimFake.removePlayer()
|
||||
zombie.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("missing attack-frequency keys fall back to the vanilla 20-tick window") {
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
lateinit var victimFake: FakePlayer
|
||||
lateinit var victim: Player
|
||||
val oldPlayerDelay = ocm.config.get("attack-frequency.playerDelay")
|
||||
val oldMobDelay = ocm.config.get("attack-frequency.mobDelay")
|
||||
|
||||
try {
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
victimFake = FakePlayer(plugin)
|
||||
victimFake.spawn(Location(world, 30.0, 100.0, 30.0))
|
||||
victim = checkNotNull(Bukkit.getPlayer(victimFake.uuid))
|
||||
|
||||
ocm.config.set("attack-frequency.playerDelay", null)
|
||||
ocm.config.set("attack-frequency.mobDelay", null)
|
||||
ocm.saveConfig()
|
||||
Config.reload()
|
||||
}
|
||||
delayTicks(2)
|
||||
runSync {
|
||||
plugin.logger.info(
|
||||
"MobInvuln[missing-keys]: maximumNoDamageTicks=${victim.maximumNoDamageTicks}"
|
||||
)
|
||||
victim.maximumNoDamageTicks shouldBe 20
|
||||
}
|
||||
} finally {
|
||||
runSync {
|
||||
ocm.config.set("attack-frequency.playerDelay", oldPlayerDelay)
|
||||
ocm.config.set("attack-frequency.mobDelay", oldMobDelay)
|
||||
ocm.saveConfig()
|
||||
Config.reload()
|
||||
victimFake.removePlayer()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,604 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@file:Suppress("ktlint:standard:package-name")
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.booleans.shouldBeFalse
|
||||
import io.kotest.matchers.booleans.shouldBeTrue
|
||||
import io.kotest.matchers.collections.shouldContain
|
||||
import io.kotest.matchers.collections.shouldContainExactly
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.api.OldCombatMechanicsAPI
|
||||
import kernitus.plugin.OldCombatMechanics.api.PlayerModesetChangeEvent
|
||||
import kernitus.plugin.OldCombatMechanics.commands.OCMCommandCompleter
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleDisableOffHand
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleSwordBlocking
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.Config
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.configuration.ConfigurationSection
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.HandlerList
|
||||
import org.bukkit.event.Listener
|
||||
import org.bukkit.event.player.PlayerJoinEvent
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class ModesetRulesIntegrationTest :
|
||||
FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
val api = Bukkit.getServicesManager().getRegistration(OldCombatMechanicsAPI::class.java)?.provider
|
||||
?: error("OldCombatMechanicsAPI service not registered")
|
||||
val module =
|
||||
ModuleLoader
|
||||
.getModules()
|
||||
.filterIsInstance<ModuleDisableOffHand>()
|
||||
.firstOrNull() ?: error("ModuleDisableOffHand not registered")
|
||||
val swordBlockingModule =
|
||||
ModuleLoader
|
||||
.getModules()
|
||||
.filterIsInstance<ModuleSwordBlocking>()
|
||||
.firstOrNull() ?: error("ModuleSwordBlocking not registered")
|
||||
val internalModules =
|
||||
setOf(
|
||||
"modeset-listener",
|
||||
"attack-cooldown-tracker",
|
||||
"entity-damage-listener"
|
||||
)
|
||||
val optionalModules =
|
||||
setOf(
|
||||
"disable-attack-sounds",
|
||||
"disable-sword-sweep-particles"
|
||||
)
|
||||
|
||||
lateinit var player: Player
|
||||
lateinit var fakePlayer: FakePlayer
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
fun runSync(action: () -> Unit) {
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit
|
||||
.getScheduler()
|
||||
.callSyncMethod(
|
||||
testPlugin,
|
||||
Callable {
|
||||
action()
|
||||
null
|
||||
}
|
||||
).get()
|
||||
}
|
||||
}
|
||||
|
||||
fun setModeset(
|
||||
player: Player,
|
||||
modeset: String
|
||||
) {
|
||||
val playerData = getPlayerData(player.uniqueId)
|
||||
playerData.setModesetForWorld(player.world.uid, modeset)
|
||||
setPlayerData(player.uniqueId, playerData)
|
||||
}
|
||||
|
||||
fun snapshotSection(path: String): Any? {
|
||||
val section = ocm.config.getConfigurationSection(path)
|
||||
return section?.getValues(false) ?: ocm.config.get(path)
|
||||
}
|
||||
|
||||
fun restoreSection(
|
||||
path: String,
|
||||
value: Any?
|
||||
) {
|
||||
ocm.config.set(path, null)
|
||||
when (value) {
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
|
||||
is Map<*, *> -> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
ocm.config.createSection(path, value as Map<String, Any?>)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ocm.config.set(path, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun applyConfigWithWorlds(
|
||||
always: List<String>,
|
||||
disabled: List<String>,
|
||||
modesets: Map<String, List<String>>,
|
||||
worlds: Map<String, List<String>>
|
||||
) {
|
||||
ocm.config.set("always_enabled_modules", always)
|
||||
ocm.config.set("disabled_modules", disabled)
|
||||
ocm.config.set("modesets", null)
|
||||
ocm.config.createSection("modesets", modesets)
|
||||
ocm.config.set("worlds", null)
|
||||
ocm.config.createSection("worlds", worlds)
|
||||
ocm.saveConfig()
|
||||
Config.reload()
|
||||
}
|
||||
|
||||
fun applyConfig(
|
||||
always: List<String>,
|
||||
disabled: List<String>,
|
||||
modesets: Map<String, List<String>>,
|
||||
worldModesets: List<String>
|
||||
) {
|
||||
applyConfigWithWorlds(
|
||||
always = always,
|
||||
disabled = disabled,
|
||||
modesets = modesets,
|
||||
worlds = mapOf("world" to worldModesets)
|
||||
)
|
||||
}
|
||||
|
||||
fun completeAlways(
|
||||
always: List<String>,
|
||||
disabled: List<String>,
|
||||
modesets: Map<String, List<String>>
|
||||
): List<String> {
|
||||
val assigned = HashSet<String>()
|
||||
always.forEach { assigned.add(it.lowercase(Locale.ROOT)) }
|
||||
disabled.forEach { assigned.add(it.lowercase(Locale.ROOT)) }
|
||||
modesets.values.flatten().forEach { assigned.add(it.lowercase(Locale.ROOT)) }
|
||||
|
||||
val filled = LinkedHashSet<String>()
|
||||
filled.addAll(always)
|
||||
ModuleLoader
|
||||
.getModules()
|
||||
.map { it.configName.lowercase(Locale.ROOT) }
|
||||
.sorted()
|
||||
.filterNot { assigned.contains(it) }
|
||||
.filterNot { internalModules.contains(it) }
|
||||
.forEach { filled.add(it) }
|
||||
optionalModules
|
||||
.filterNot { assigned.contains(it) }
|
||||
.forEach { filled.add(it) }
|
||||
return filled.toList()
|
||||
}
|
||||
|
||||
fun applySimpleModesetWorlds(worlds: Map<String, List<String>>) {
|
||||
val modesets =
|
||||
mapOf(
|
||||
"old" to listOf("disable-offhand"),
|
||||
"new" to listOf("old-potion-effects")
|
||||
)
|
||||
applyConfigWithWorlds(
|
||||
always =
|
||||
completeAlways(
|
||||
always = emptyList(),
|
||||
disabled = emptyList(),
|
||||
modesets = modesets
|
||||
),
|
||||
disabled = emptyList(),
|
||||
modesets = modesets,
|
||||
worlds = worlds
|
||||
)
|
||||
}
|
||||
|
||||
fun clearPendingSaveTask() {
|
||||
val field = PlayerStorage::class.java.getDeclaredField("saveTask")
|
||||
field.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val saveTask = field.get(null) as AtomicReference<Any?>
|
||||
val pending = saveTask.getAndSet(null)
|
||||
pending?.javaClass?.getMethod("cancel")?.invoke(pending)
|
||||
}
|
||||
|
||||
suspend fun withConfig(block: suspend () -> Unit) {
|
||||
val originalAlways = ocm.config.get("always_enabled_modules")
|
||||
val originalDisabled = ocm.config.get("disabled_modules")
|
||||
val originalModesets = snapshotSection("modesets")
|
||||
val originalWorlds = snapshotSection("worlds")
|
||||
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
runSync {
|
||||
ocm.config.set("always_enabled_modules", originalAlways)
|
||||
ocm.config.set("disabled_modules", originalDisabled)
|
||||
restoreSection("modesets", originalModesets)
|
||||
restoreSection("worlds", originalWorlds)
|
||||
ocm.saveConfig()
|
||||
Config.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeSpec {
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getServer().getWorld("world"))
|
||||
fakePlayer = FakePlayer(testPlugin)
|
||||
fakePlayer.spawn(Location(world, 0.0, 100.0, 0.0))
|
||||
player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))
|
||||
}
|
||||
}
|
||||
|
||||
afterSpec {
|
||||
runSync {
|
||||
fakePlayer.removePlayer()
|
||||
}
|
||||
}
|
||||
|
||||
test("always-enabled modules apply regardless of modeset") {
|
||||
withConfig {
|
||||
runSync {
|
||||
applyConfig(
|
||||
always =
|
||||
completeAlways(
|
||||
always = listOf("disable-offhand"),
|
||||
disabled = emptyList(),
|
||||
modesets =
|
||||
mapOf(
|
||||
"old" to listOf("old-golden-apples"),
|
||||
"new" to listOf("old-potion-effects")
|
||||
)
|
||||
),
|
||||
disabled = emptyList(),
|
||||
modesets =
|
||||
mapOf(
|
||||
"old" to listOf("old-golden-apples"),
|
||||
"new" to listOf("old-potion-effects")
|
||||
),
|
||||
worldModesets = listOf("old", "new")
|
||||
)
|
||||
|
||||
setModeset(player, "old")
|
||||
module.isEnabled(player).shouldBeTrue()
|
||||
|
||||
setModeset(player, "new")
|
||||
module.isEnabled(player).shouldBeTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("disabled modules never apply") {
|
||||
withConfig {
|
||||
runSync {
|
||||
applyConfig(
|
||||
always =
|
||||
completeAlways(
|
||||
always = emptyList(),
|
||||
disabled = listOf("disable-offhand"),
|
||||
modesets =
|
||||
mapOf(
|
||||
"old" to listOf("old-golden-apples"),
|
||||
"new" to listOf("old-potion-effects")
|
||||
)
|
||||
),
|
||||
disabled = listOf("disable-offhand"),
|
||||
modesets =
|
||||
mapOf(
|
||||
"old" to listOf("old-golden-apples"),
|
||||
"new" to listOf("old-potion-effects")
|
||||
),
|
||||
worldModesets = listOf("old", "new")
|
||||
)
|
||||
|
||||
setModeset(player, "old")
|
||||
module.isEnabled(player).shouldBeFalse()
|
||||
|
||||
setModeset(player, "new")
|
||||
module.isEnabled(player).shouldBeFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("modeset membership controls module activation") {
|
||||
withConfig {
|
||||
runSync {
|
||||
applyConfig(
|
||||
always =
|
||||
completeAlways(
|
||||
always = emptyList(),
|
||||
disabled = emptyList(),
|
||||
modesets =
|
||||
mapOf(
|
||||
"old" to listOf("disable-offhand"),
|
||||
"new" to listOf("old-potion-effects")
|
||||
)
|
||||
),
|
||||
disabled = emptyList(),
|
||||
modesets =
|
||||
mapOf(
|
||||
"old" to listOf("disable-offhand"),
|
||||
"new" to listOf("old-potion-effects")
|
||||
),
|
||||
worldModesets = listOf("old", "new")
|
||||
)
|
||||
|
||||
setModeset(player, "old")
|
||||
module.isEnabled(player).shouldBeTrue()
|
||||
|
||||
setModeset(player, "new")
|
||||
module.isEnabled(player).shouldBeFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("stale stored modeset is not trusted when current world only allows another modeset") {
|
||||
withConfig {
|
||||
runSync {
|
||||
applyConfig(
|
||||
always =
|
||||
completeAlways(
|
||||
always = emptyList(),
|
||||
disabled = emptyList(),
|
||||
modesets =
|
||||
mapOf(
|
||||
"old" to listOf("sword-blocking"),
|
||||
"new" to listOf("old-potion-effects")
|
||||
)
|
||||
),
|
||||
disabled = emptyList(),
|
||||
modesets =
|
||||
mapOf(
|
||||
"old" to listOf("sword-blocking"),
|
||||
"new" to listOf("old-potion-effects")
|
||||
),
|
||||
worldModesets = listOf("new")
|
||||
)
|
||||
|
||||
setModeset(player, "old")
|
||||
Bukkit.getPluginManager().callEvent(PlayerJoinEvent(player, ""))
|
||||
|
||||
getPlayerData(player.uniqueId).getModesetForWorld(player.world.uid) shouldBe "new"
|
||||
swordBlockingModule.isEnabled(player).shouldBeFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("listed world modesets override default world modesets") {
|
||||
withConfig {
|
||||
runSync {
|
||||
applySimpleModesetWorlds(
|
||||
mapOf(
|
||||
"__default__" to listOf("old"),
|
||||
"world" to listOf("new", "old")
|
||||
)
|
||||
)
|
||||
|
||||
Config.getAllowedModesets(player.world.uid).toList().shouldContainExactly("new", "old")
|
||||
Config.getDefaultModeset(player.world.uid) shouldBe Config.getModesets()["new"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("unlisted worlds use default world modesets") {
|
||||
withConfig {
|
||||
runSync {
|
||||
applySimpleModesetWorlds(mapOf("__default__" to listOf("old")))
|
||||
|
||||
Config.getAllowedModesets(player.world.uid).toList().shouldContainExactly("old")
|
||||
Config.getDefaultModeset(player.world.uid) shouldBe Config.getModesets()["old"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("empty listed world modesets remain unrestricted despite default world modesets") {
|
||||
withConfig {
|
||||
runSync {
|
||||
applySimpleModesetWorlds(
|
||||
mapOf(
|
||||
"__default__" to listOf("old"),
|
||||
"world" to emptyList()
|
||||
)
|
||||
)
|
||||
|
||||
val allowedModesets = Config.getAllowedModesets(player.world.uid)
|
||||
allowedModesets.shouldContain("old")
|
||||
allowedModesets.shouldContain("new")
|
||||
Config.getDefaultModeset(player.world.uid) shouldBe null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("mode command and tab completion use default world modesets") {
|
||||
withConfig {
|
||||
runSync {
|
||||
applySimpleModesetWorlds(mapOf("__default__" to listOf("old")))
|
||||
player.addAttachment(ocm, "oldcombatmechanics.commands", true)
|
||||
player.addAttachment(ocm, "oldcombatmechanics.mode", true)
|
||||
player.addAttachment(ocm, "oldcombatmechanics.mode.own", true)
|
||||
|
||||
val command = checkNotNull(Bukkit.getPluginCommand("oldcombatmechanics"))
|
||||
val completions = OCMCommandCompleter().onTabComplete(player, command, "ocm", arrayOf("mode", ""))
|
||||
completions.shouldContainExactly("old")
|
||||
|
||||
setModeset(player, "old")
|
||||
Bukkit.dispatchCommand(player, "ocm mode new")
|
||||
getPlayerData(player.uniqueId).getModesetForWorld(player.world.uid) shouldBe "old"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("modeset API stores valid modesets and normalises names") {
|
||||
withConfig {
|
||||
runSync {
|
||||
applySimpleModesetWorlds(mapOf("world" to listOf("old", "new")))
|
||||
val events = mutableListOf<PlayerModesetChangeEvent>()
|
||||
val listener = object : Listener {
|
||||
@EventHandler
|
||||
fun onModesetChange(event: PlayerModesetChangeEvent) {
|
||||
if (event.player.uniqueId == player.uniqueId) {
|
||||
events += event
|
||||
}
|
||||
}
|
||||
}
|
||||
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
|
||||
|
||||
try {
|
||||
setModeset(player, "old")
|
||||
api.getModesetNames().toList().shouldContainExactly("old", "new")
|
||||
api.getAllowedModesets(player.world).toList().shouldContainExactly("old", "new")
|
||||
|
||||
api.setModesetForPlayer(player, "new")
|
||||
api.getModesetForPlayer(player) shouldBe "new"
|
||||
getPlayerData(player.uniqueId).getModesetForWorld(player.world.uid) shouldBe "new"
|
||||
events.single().previousModeset shouldBe "old"
|
||||
events.single().newModeset shouldBe "new"
|
||||
events.single().reason shouldBe PlayerModesetChangeEvent.Reason.API
|
||||
|
||||
events.clear()
|
||||
api.setModesetForPlayer(player, "new")
|
||||
events shouldBe emptyList()
|
||||
|
||||
api.setModesetForPlayer(player, "OLD")
|
||||
api.getModesetForPlayer(player) shouldBe "old"
|
||||
getPlayerData(player.uniqueId).getModesetForWorld(player.world.uid) shouldBe "old"
|
||||
events.single().previousModeset shouldBe "new"
|
||||
events.single().newModeset shouldBe "old"
|
||||
events.single().reason shouldBe PlayerModesetChangeEvent.Reason.API
|
||||
} finally {
|
||||
HandlerList.unregisterAll(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("mode command emits command-reason modeset change events") {
|
||||
withConfig {
|
||||
runSync {
|
||||
applySimpleModesetWorlds(mapOf("world" to listOf("old", "new")))
|
||||
player.addAttachment(ocm, "oldcombatmechanics.commands", true)
|
||||
player.addAttachment(ocm, "oldcombatmechanics.mode", true)
|
||||
player.addAttachment(ocm, "oldcombatmechanics.mode.own", true)
|
||||
setModeset(player, "old")
|
||||
val events = mutableListOf<PlayerModesetChangeEvent>()
|
||||
val listener = object : Listener {
|
||||
@EventHandler
|
||||
fun onModesetChange(event: PlayerModesetChangeEvent) {
|
||||
if (event.player.uniqueId == player.uniqueId) {
|
||||
events += event
|
||||
}
|
||||
}
|
||||
}
|
||||
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
|
||||
|
||||
try {
|
||||
Bukkit.dispatchCommand(player, "ocm mode new") shouldBe true
|
||||
getPlayerData(player.uniqueId).getModesetForWorld(player.world.uid) shouldBe "new"
|
||||
events.single().previousModeset shouldBe "old"
|
||||
events.single().newModeset shouldBe "new"
|
||||
events.single().reason shouldBe PlayerModesetChangeEvent.Reason.COMMAND
|
||||
|
||||
events.clear()
|
||||
Bukkit.dispatchCommand(player, "ocm mode new") shouldBe true
|
||||
events shouldBe emptyList()
|
||||
} finally {
|
||||
HandlerList.unregisterAll(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("modeset API rejects modesets disallowed in the player world") {
|
||||
withConfig {
|
||||
runSync {
|
||||
applySimpleModesetWorlds(mapOf("world" to listOf("old")))
|
||||
setModeset(player, "old")
|
||||
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
api.setModesetForPlayer(player, "new")
|
||||
}
|
||||
|
||||
api.getModesetForPlayer(player) shouldBe "old"
|
||||
getPlayerData(player.uniqueId).getModesetForWorld(player.world.uid) shouldBe "old"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("modeset API no-op set does not schedule a player data save") {
|
||||
withConfig {
|
||||
runSync {
|
||||
applySimpleModesetWorlds(mapOf("world" to listOf("old", "new")))
|
||||
setModeset(player, "old")
|
||||
clearPendingSaveTask()
|
||||
|
||||
api.setModesetForPlayer(player, "old")
|
||||
|
||||
val field = PlayerStorage::class.java.getDeclaredField("saveTask")
|
||||
field.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val saveTask = field.get(null) as AtomicReference<Any?>
|
||||
saveTask.get() shouldBe null
|
||||
api.getModesetForPlayer(player) shouldBe "old"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("modules in disabled and another list fail reload") {
|
||||
withConfig {
|
||||
runSync {
|
||||
shouldThrow<IllegalStateException> {
|
||||
applyConfig(
|
||||
always =
|
||||
completeAlways(
|
||||
always = listOf("disable-offhand"),
|
||||
disabled = listOf("disable-offhand"),
|
||||
modesets =
|
||||
mapOf(
|
||||
"old" to listOf("old-potion-effects"),
|
||||
"new" to listOf("old-golden-apples")
|
||||
)
|
||||
),
|
||||
disabled = listOf("disable-offhand"),
|
||||
modesets =
|
||||
mapOf(
|
||||
"old" to listOf("old-potion-effects"),
|
||||
"new" to listOf("old-golden-apples")
|
||||
),
|
||||
worldModesets = listOf("old", "new")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("modules missing from all lists fail reload") {
|
||||
withConfig {
|
||||
runSync {
|
||||
val moduleNames =
|
||||
(
|
||||
ModuleLoader
|
||||
.getModules()
|
||||
.map { it.configName }
|
||||
.filterNot { internalModules.contains(it) } + optionalModules
|
||||
).distinct()
|
||||
.sorted()
|
||||
val missing = moduleNames.firstOrNull() ?: error("No modules registered")
|
||||
val always = moduleNames.filterNot { it == missing }
|
||||
|
||||
shouldThrow<IllegalStateException> {
|
||||
applyConfig(
|
||||
always = always,
|
||||
disabled = emptyList(),
|
||||
modesets = mapOf("old" to emptyList()),
|
||||
worldModesets = listOf("old")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import org.bukkit.inventory.ItemStack
|
||||
|
||||
class OCMTest(
|
||||
val weapon: ItemStack,
|
||||
val armour: Array<ItemStack>,
|
||||
val attackDelay: Long,
|
||||
val message: String,
|
||||
val preparations: Runnable
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.util.logging.Level
|
||||
|
||||
class OCMTestMain : JavaPlugin() {
|
||||
override fun onEnable() {
|
||||
logger.info("Enabled OCMTest plugin")
|
||||
|
||||
// Initialise player data storage
|
||||
val ocm = Bukkit.getPluginManager().getPlugin("OldCombatMechanics") as OCMMain
|
||||
PlayerStorage.initialise(ocm)
|
||||
|
||||
val javaVersion = detectJavaVersion()
|
||||
logger.info("Detected Java $javaVersion for integration tests")
|
||||
|
||||
System.setProperty("kotest.framework.classpath.scanning.autoscan.disable", "true")
|
||||
runKotest()
|
||||
}
|
||||
|
||||
private fun runKotest() {
|
||||
try {
|
||||
val runnerClass = Class.forName("kernitus.plugin.OldCombatMechanics.KotestRunner")
|
||||
val runMethod = runnerClass.getMethod("run", JavaPlugin::class.java)
|
||||
runMethod.invoke(null, this)
|
||||
} catch (e: InvocationTargetException) {
|
||||
logger.log(Level.SEVERE, "Failed to launch Kotest runner.", e.targetException ?: e)
|
||||
TestResultWriter.writeAndShutdown(this, false)
|
||||
} catch (e: Throwable) {
|
||||
logger.log(Level.SEVERE, "Failed to launch Kotest runner.", e)
|
||||
TestResultWriter.writeAndShutdown(this, false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectJavaVersion(): Int {
|
||||
val version = System.getProperty("java.specification.version") ?: return 0
|
||||
return if (version.startsWith("1.")) {
|
||||
version.substringAfter("1.").toIntOrNull() ?: 0
|
||||
} else {
|
||||
version.toIntOrNull() ?: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.core.test.TestScope
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleOldArmourDurability
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.entity.EntityDamageEvent
|
||||
import org.bukkit.event.player.PlayerItemDamageEvent
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class OldArmourDurabilityIntegrationTest : FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
val module = ModuleLoader.getModules()
|
||||
.filterIsInstance<ModuleOldArmourDurability>()
|
||||
.firstOrNull() ?: error("ModuleOldArmourDurability not registered")
|
||||
|
||||
lateinit var player: Player
|
||||
lateinit var fakePlayer: FakePlayer
|
||||
|
||||
fun runSync(action: () -> Unit) {
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
|
||||
action()
|
||||
null
|
||||
}).get()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun TestScope.withConfig(block: suspend TestScope.() -> Unit) {
|
||||
val reduction = ocm.config.getInt("old-armour-durability.reduction")
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
ocm.config.set("old-armour-durability.reduction", reduction)
|
||||
module.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
}
|
||||
}
|
||||
|
||||
fun setModeset(modeset: String) {
|
||||
val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
|
||||
playerData.setModesetForWorld(player.world.uid, modeset)
|
||||
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)
|
||||
}
|
||||
|
||||
fun createItemDamageEvent(item: ItemStack, damage: Int): PlayerItemDamageEvent {
|
||||
val ctor = PlayerItemDamageEvent::class.java.constructors.firstOrNull { constructor ->
|
||||
val params = constructor.parameterTypes
|
||||
params.size == 4 &&
|
||||
Player::class.java.isAssignableFrom(params[0]) &&
|
||||
ItemStack::class.java.isAssignableFrom(params[1]) &&
|
||||
params[2] == Int::class.javaPrimitiveType &&
|
||||
params[3] == Int::class.javaPrimitiveType
|
||||
}
|
||||
return if (ctor != null) {
|
||||
ctor.newInstance(player, item, damage, damage) as PlayerItemDamageEvent
|
||||
} else {
|
||||
PlayerItemDamageEvent(player, item, damage)
|
||||
}
|
||||
}
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
beforeSpec {
|
||||
runSync {
|
||||
val world = Bukkit.getServer().getWorld("world")
|
||||
val location = Location(world, 0.0, 100.0, 0.0)
|
||||
fakePlayer = FakePlayer(testPlugin)
|
||||
fakePlayer.spawn(location)
|
||||
player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))
|
||||
player.isOp = true
|
||||
setModeset("old")
|
||||
}
|
||||
}
|
||||
|
||||
afterSpec {
|
||||
runSync {
|
||||
fakePlayer.removePlayer()
|
||||
}
|
||||
}
|
||||
|
||||
beforeTest {
|
||||
runSync {
|
||||
player.inventory.clear()
|
||||
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
|
||||
setModeset("old")
|
||||
module.reload()
|
||||
}
|
||||
}
|
||||
|
||||
context("Armour durability reduction") {
|
||||
test("worn armour takes reduced durability") {
|
||||
withConfig {
|
||||
ocm.config.set("old-armour-durability.reduction", 2)
|
||||
module.reload()
|
||||
|
||||
val helmet = ItemStack(Material.DIAMOND_HELMET)
|
||||
player.inventory.helmet = helmet
|
||||
|
||||
val event = createItemDamageEvent(helmet, 5)
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
|
||||
event.damage shouldBe 2
|
||||
}
|
||||
}
|
||||
|
||||
test("non-armour items are ignored") {
|
||||
withConfig {
|
||||
ocm.config.set("old-armour-durability.reduction", 2)
|
||||
module.reload()
|
||||
|
||||
val sword = ItemStack(Material.DIAMOND_SWORD)
|
||||
player.inventory.setItemInMainHand(sword)
|
||||
|
||||
val event = createItemDamageEvent(sword, 5)
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
|
||||
event.damage shouldBe 5
|
||||
}
|
||||
}
|
||||
|
||||
test("elytra is ignored") {
|
||||
withConfig {
|
||||
ocm.config.set("old-armour-durability.reduction", 2)
|
||||
module.reload()
|
||||
|
||||
val elytra = ItemStack(Material.ELYTRA)
|
||||
player.inventory.chestplate = elytra
|
||||
|
||||
val event = createItemDamageEvent(elytra, 5)
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
|
||||
event.damage shouldBe 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context("Explosion handling") {
|
||||
test("explosion damage bypasses durability reduction") {
|
||||
withConfig {
|
||||
ocm.config.set("old-armour-durability.reduction", 2)
|
||||
module.reload()
|
||||
|
||||
val helmet = ItemStack(Material.DIAMOND_HELMET)
|
||||
player.inventory.helmet = helmet
|
||||
|
||||
val explosion = EntityDamageEvent(player, EntityDamageEvent.DamageCause.BLOCK_EXPLOSION, 6.0)
|
||||
Bukkit.getPluginManager().callEvent(explosion)
|
||||
|
||||
val event = createItemDamageEvent(helmet, 5)
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
|
||||
event.damage shouldBe 5
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@file:Suppress("ktlint:standard:package-name")
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import com.google.common.base.Function
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.doubles.plusOrMinus
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleOldArmourStrength
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.Config
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.entity.EntityDamageEvent
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.util.EnumMap
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class OldArmourStrengthModesetIntegrationTest :
|
||||
FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
val module =
|
||||
ModuleLoader
|
||||
.getModules()
|
||||
.filterIsInstance<ModuleOldArmourStrength>()
|
||||
.firstOrNull() ?: error("ModuleOldArmourStrength not registered")
|
||||
|
||||
lateinit var player: Player
|
||||
lateinit var fakePlayer: FakePlayer
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
fun runSync(action: () -> Unit) {
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit
|
||||
.getScheduler()
|
||||
.callSyncMethod(
|
||||
testPlugin,
|
||||
Callable {
|
||||
action()
|
||||
null
|
||||
}
|
||||
).get()
|
||||
}
|
||||
}
|
||||
|
||||
fun snapshotSection(path: String): Any? {
|
||||
val section = ocm.config.getConfigurationSection(path)
|
||||
return section?.getValues(false) ?: ocm.config.get(path)
|
||||
}
|
||||
|
||||
fun restoreSection(
|
||||
path: String,
|
||||
value: Any?
|
||||
) {
|
||||
ocm.config.set(path, null)
|
||||
when (value) {
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
|
||||
is Map<*, *> -> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
ocm.config.createSection(path, value as Map<String, Any?>)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ocm.config.set(path, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun configurableModules(): List<String> {
|
||||
val internalModules =
|
||||
setOf(
|
||||
"modeset-listener",
|
||||
"attack-cooldown-tracker",
|
||||
"entity-damage-listener"
|
||||
)
|
||||
val optionalModules =
|
||||
setOf(
|
||||
"disable-attack-sounds",
|
||||
"disable-sword-sweep-particles"
|
||||
)
|
||||
|
||||
return (
|
||||
ModuleLoader
|
||||
.getModules()
|
||||
.map { it.configName.lowercase(Locale.ROOT) }
|
||||
.filterNot { internalModules.contains(it) } + optionalModules
|
||||
).distinct().sorted()
|
||||
}
|
||||
|
||||
fun reloadConfigAndModules() {
|
||||
ocm.saveConfig()
|
||||
Config.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
module.reload()
|
||||
}
|
||||
|
||||
fun applyIssue861Config() {
|
||||
val oldArmourStrength = "old-armour-strength"
|
||||
ocm.config.set("always_enabled_modules", emptyList<String>())
|
||||
ocm.config.set("disabled_modules", configurableModules().filterNot { it == oldArmourStrength })
|
||||
ocm.config.set("modesets", null)
|
||||
ocm.config.createSection(
|
||||
"modesets",
|
||||
mapOf(
|
||||
"old" to listOf(oldArmourStrength),
|
||||
"new" to emptyList<String>()
|
||||
)
|
||||
)
|
||||
ocm.config.set("worlds", null)
|
||||
ocm.config.createSection("worlds", mapOf("world" to listOf("new")))
|
||||
reloadConfigAndModules()
|
||||
}
|
||||
|
||||
suspend fun withIssue861Config(block: suspend () -> Unit) {
|
||||
val originalAlways = ocm.config.get("always_enabled_modules")
|
||||
val originalDisabled = ocm.config.get("disabled_modules")
|
||||
val originalModesets = snapshotSection("modesets")
|
||||
val originalWorlds = snapshotSection("worlds")
|
||||
|
||||
try {
|
||||
applyIssue861Config()
|
||||
block()
|
||||
} finally {
|
||||
runSync {
|
||||
ocm.config.set("always_enabled_modules", originalAlways)
|
||||
ocm.config.set("disabled_modules", originalDisabled)
|
||||
restoreSection("modesets", originalModesets)
|
||||
restoreSection("worlds", originalWorlds)
|
||||
reloadConfigAndModules()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setModeset(modeset: String) {
|
||||
val playerData = getPlayerData(player.uniqueId)
|
||||
playerData.setModesetForWorld(player.world.uid, modeset)
|
||||
setPlayerData(player.uniqueId, playerData)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun createExplosionDamageEvent(): EntityDamageEvent {
|
||||
val modifiers =
|
||||
EnumMap<EntityDamageEvent.DamageModifier, Double>(
|
||||
EntityDamageEvent.DamageModifier::class.java
|
||||
)
|
||||
modifiers[EntityDamageEvent.DamageModifier.BASE] = 20.0
|
||||
modifiers[EntityDamageEvent.DamageModifier.ARMOR] = -8.0
|
||||
modifiers[EntityDamageEvent.DamageModifier.MAGIC] = -2.0
|
||||
|
||||
val identity =
|
||||
object : Function<Double, Double> {
|
||||
override fun apply(input: Double): Double = input
|
||||
}
|
||||
val modifierFunctions =
|
||||
EnumMap<EntityDamageEvent.DamageModifier, Function<in Double, Double>>(
|
||||
EntityDamageEvent.DamageModifier::class.java
|
||||
)
|
||||
modifiers.keys.forEach { modifierFunctions[it] = identity }
|
||||
|
||||
return EntityDamageEvent(
|
||||
player,
|
||||
EntityDamageEvent.DamageCause.BLOCK_EXPLOSION,
|
||||
modifiers,
|
||||
modifierFunctions
|
||||
)
|
||||
}
|
||||
|
||||
beforeSpec {
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getServer().getWorld("world"))
|
||||
fakePlayer = FakePlayer(testPlugin)
|
||||
fakePlayer.spawn(Location(world, 0.0, 100.0, 0.0))
|
||||
player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))
|
||||
}
|
||||
}
|
||||
|
||||
afterSpec {
|
||||
runSync {
|
||||
fakePlayer.removePlayer()
|
||||
}
|
||||
}
|
||||
|
||||
beforeTest {
|
||||
runSync {
|
||||
player.inventory.clear()
|
||||
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
|
||||
player.inventory.chestplate = ItemStack(Material.DIAMOND_CHESTPLATE)
|
||||
}
|
||||
}
|
||||
|
||||
test("old-armour-strength leaves explosion modifiers unchanged in new modeset") {
|
||||
withIssue861Config {
|
||||
runSync {
|
||||
setModeset("new")
|
||||
|
||||
val event = createExplosionDamageEvent()
|
||||
val originalArmour = event.getDamage(EntityDamageEvent.DamageModifier.ARMOR)
|
||||
val originalMagic = event.getDamage(EntityDamageEvent.DamageModifier.MAGIC)
|
||||
val originalFinalDamage = event.finalDamage
|
||||
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
|
||||
event.getDamage(EntityDamageEvent.DamageModifier.ARMOR) shouldBe (originalArmour plusOrMinus 0.0001)
|
||||
event.getDamage(EntityDamageEvent.DamageModifier.MAGIC) shouldBe (originalMagic plusOrMinus 0.0001)
|
||||
event.finalDamage shouldBe (originalFinalDamage plusOrMinus 0.0001)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("old-armour-strength applies old armour modifiers to explosion damage in old modeset") {
|
||||
withIssue861Config {
|
||||
runSync {
|
||||
setModeset("old")
|
||||
|
||||
val event = createExplosionDamageEvent()
|
||||
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
|
||||
event.getDamage(EntityDamageEvent.DamageModifier.ARMOR) shouldBe (-6.4 plusOrMinus 0.0001)
|
||||
event.getDamage(EntityDamageEvent.DamageModifier.MAGIC) shouldBe (0.0 plusOrMinus 0.0001)
|
||||
event.finalDamage shouldBe (13.6 plusOrMinus 0.0001)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,481 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import com.cryptomorin.xseries.XAttribute
|
||||
import com.cryptomorin.xseries.XMaterial
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.assertions.withClue
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.core.test.TestScope
|
||||
import io.kotest.matchers.doubles.plusOrMinus
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleOldCriticalHits
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleOldToolDamage
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.damage.DamageUtils
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.damage.OCMEntityDamageByEntityEvent
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
|
||||
import kotlinx.coroutines.delay
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.attribute.AttributeModifier
|
||||
import org.bukkit.entity.LivingEntity
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.HandlerList
|
||||
import org.bukkit.event.Listener
|
||||
import org.bukkit.event.entity.EntityDamageEvent
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent
|
||||
import org.bukkit.inventory.EquipmentSlot
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import org.bukkit.util.Vector
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.UUID
|
||||
import kotlin.math.abs
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class OldCriticalHitsIntegrationTest : FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
val criticalModule = ModuleLoader.getModules()
|
||||
.filterIsInstance<ModuleOldCriticalHits>()
|
||||
.firstOrNull() ?: error("ModuleOldCriticalHits not registered")
|
||||
val toolDamageModule = ModuleLoader.getModules()
|
||||
.filterIsInstance<ModuleOldToolDamage>()
|
||||
.firstOrNull() ?: error("ModuleOldToolDamage not registered")
|
||||
|
||||
lateinit var attacker: Player
|
||||
lateinit var fakeAttacker: FakePlayer
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
val isLegacy = !Reflector.versionIsNewerOrEqualTo(1, 13, 0)
|
||||
val legacySpeedModifierId = UUID.fromString("c1f6010f-4d2e-4b2e-9a2f-3f0d0f1b2e3c")
|
||||
fun runSync(action: () -> Unit) {
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
|
||||
action()
|
||||
null
|
||||
}).get()
|
||||
}
|
||||
}
|
||||
|
||||
fun setOnGround(player: Player, onGround: Boolean) {
|
||||
val handle = player.javaClass.getMethod("getHandle").invoke(player)
|
||||
val field = generateSequence(handle.javaClass) { it.superclass }
|
||||
.mapNotNull { klass ->
|
||||
runCatching { klass.getDeclaredField("onGround") }.getOrNull()
|
||||
}
|
||||
.firstOrNull()
|
||||
field?.let {
|
||||
it.isAccessible = true
|
||||
it.setBoolean(handle, onGround)
|
||||
return
|
||||
}
|
||||
val setOnGroundMethod = generateSequence(handle.javaClass) { it.superclass }
|
||||
.mapNotNull { klass ->
|
||||
runCatching { klass.getDeclaredMethod("setOnGround", Boolean::class.javaPrimitiveType) }.getOrNull()
|
||||
}
|
||||
.firstOrNull()
|
||||
setOnGroundMethod?.let {
|
||||
it.isAccessible = true
|
||||
it.invoke(handle, onGround)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delayTicks(ticks: Long) {
|
||||
delay(ticks * 50L)
|
||||
}
|
||||
|
||||
fun prepareWeapon(item: ItemStack) {
|
||||
val meta = item.itemMeta ?: return
|
||||
val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get() ?: return
|
||||
val speedModifier = createAttributeModifier(
|
||||
name = "speed",
|
||||
amount = 1000.0,
|
||||
operation = AttributeModifier.Operation.ADD_NUMBER,
|
||||
slot = EquipmentSlot.HAND
|
||||
)
|
||||
addAttributeModifierCompat(meta, attackSpeedAttribute, speedModifier)
|
||||
item.itemMeta = meta
|
||||
}
|
||||
|
||||
fun applyAttackDamageModifiers(player: Player, item: ItemStack) {
|
||||
if (isLegacy) {
|
||||
val attackSpeedAttribute = XAttribute.ATTACK_SPEED.get()
|
||||
val speedAttribute = attackSpeedAttribute?.let { player.getAttribute(it) }
|
||||
speedAttribute
|
||||
?.modifiers
|
||||
?.filter { it.uniqueId == legacySpeedModifierId }
|
||||
?.forEach { speedAttribute.removeModifier(it) }
|
||||
val speedModifier = createAttributeModifier(
|
||||
name = "ocm-legacy-speed",
|
||||
amount = 1000.0,
|
||||
operation = AttributeModifier.Operation.ADD_NUMBER,
|
||||
slot = EquipmentSlot.HAND,
|
||||
uuid = legacySpeedModifierId
|
||||
)
|
||||
speedAttribute?.addModifier(speedModifier)
|
||||
return
|
||||
}
|
||||
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get() ?: return
|
||||
val attackAttribute = player.getAttribute(attackDamageAttribute) ?: return
|
||||
val modifiers = getDefaultAttributeModifiersCompat(item, EquipmentSlot.HAND, attackDamageAttribute)
|
||||
val expectedAmounts = modifiers
|
||||
.filter { it.operation == AttributeModifier.Operation.ADD_NUMBER }
|
||||
.map { it.amount }
|
||||
val knownWeaponAmounts = NewWeaponDamage.values()
|
||||
.map { it.damage.toDouble() - 1.0 }
|
||||
.filter { it > 0.0 }
|
||||
.toSet()
|
||||
|
||||
fun matchesAmount(first: Double, second: Double): Boolean = abs(first - second) <= 0.0001
|
||||
|
||||
val existingModifiers = attackAttribute.modifiers.toList()
|
||||
existingModifiers
|
||||
.filter { it.operation == AttributeModifier.Operation.ADD_NUMBER && it.amount > 0.0 }
|
||||
.filter { modifier ->
|
||||
knownWeaponAmounts.any { matchesAmount(it, modifier.amount) } &&
|
||||
expectedAmounts.none { expected -> matchesAmount(expected, modifier.amount) }
|
||||
}
|
||||
.forEach { attackAttribute.removeModifier(it) }
|
||||
|
||||
modifiers.forEach { modifier ->
|
||||
val alreadyApplied = attackAttribute.modifiers.any {
|
||||
it.operation == modifier.operation && matchesAmount(it.amount, modifier.amount)
|
||||
}
|
||||
if (!alreadyApplied) {
|
||||
attackAttribute.addModifier(modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun equip(player: Player, item: ItemStack) {
|
||||
if (isLegacy) {
|
||||
// On legacy versions, avoid mutating item meta; directly adjust player attributes instead.
|
||||
val meta = item.itemMeta
|
||||
if (meta != null) {
|
||||
runCatching {
|
||||
XAttribute.ATTACK_DAMAGE.get()?.let { meta.removeAttributeModifier(it) }
|
||||
XAttribute.ATTACK_SPEED.get()?.let { meta.removeAttributeModifier(it) }
|
||||
}
|
||||
runCatching { meta.removeAttributeModifier(EquipmentSlot.HAND) }
|
||||
runCatching { meta.removeAttributeModifier(EquipmentSlot.OFF_HAND) }
|
||||
item.itemMeta = meta
|
||||
}
|
||||
|
||||
val useDamageAttribute = !Reflector.versionIsNewerOrEqualTo(1, 12, 0)
|
||||
if (useDamageAttribute) {
|
||||
val attackDamageAttribute = XAttribute.ATTACK_DAMAGE.get()
|
||||
val damageAttribute = attackDamageAttribute?.let { player.getAttribute(it) }
|
||||
val configuredDamage = WeaponDamages.getDamage(item.type).toDouble().takeIf { it > 0 }
|
||||
?: (NewWeaponDamage.getDamageOrNull(item.type) ?: 1.0f).toDouble()
|
||||
damageAttribute?.baseValue = configuredDamage
|
||||
}
|
||||
player.inventory.setItemInMainHand(item)
|
||||
applyAttackDamageModifiers(player, item)
|
||||
player.updateInventory()
|
||||
return
|
||||
}
|
||||
|
||||
prepareWeapon(item)
|
||||
player.inventory.setItemInMainHand(item)
|
||||
applyAttackDamageModifiers(player, item)
|
||||
player.updateInventory()
|
||||
}
|
||||
|
||||
fun spawnVictim(location: Location): LivingEntity {
|
||||
val world = location.world ?: error("World missing for victim spawn")
|
||||
return world.spawn(location, org.bukkit.entity.Cow::class.java).apply {
|
||||
maximumNoDamageTicks = 0
|
||||
noDamageTicks = 0
|
||||
isInvulnerable = false
|
||||
health = maxHealth
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun hitAndCaptureDamage(
|
||||
weapon: ItemStack,
|
||||
critical: Boolean
|
||||
): Double {
|
||||
val events = mutableListOf<EntityDamageByEntityEvent>()
|
||||
val ocmEvents = mutableListOf<OCMEntityDamageByEntityEvent>()
|
||||
lateinit var victim: LivingEntity
|
||||
|
||||
val listener = object : Listener {
|
||||
@EventHandler
|
||||
fun onDamage(event: EntityDamageByEntityEvent) {
|
||||
if (event.damager.uniqueId == attacker.uniqueId &&
|
||||
event.entity.uniqueId == victim.uniqueId
|
||||
) {
|
||||
events.add(event)
|
||||
testPlugin.logger.info(
|
||||
"Critical hit debug: weapon=${attacker.inventory.itemInMainHand.type} " +
|
||||
"critical=$critical sprinting=${attacker.isSprinting} " +
|
||||
"fallDistance=${attacker.fallDistance} onGround=${attacker.isOnGround} " +
|
||||
"damage=${event.damage} finalDamage=${event.finalDamage}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
fun onOcm(event: OCMEntityDamageByEntityEvent) {
|
||||
if (event.damager.uniqueId == attacker.uniqueId &&
|
||||
event.damagee.uniqueId == victim.uniqueId
|
||||
) {
|
||||
ocmEvents.add(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val victimLocation = Location(world, 1.2, 100.0, 0.0)
|
||||
victim = spawnVictim(victimLocation)
|
||||
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
|
||||
|
||||
equip(attacker, weapon)
|
||||
}
|
||||
delayTicks(1)
|
||||
if (isLegacy) {
|
||||
// Vanilla 1.12 applies attack cooldown scaling before the Bukkit damage event fires.
|
||||
// Give the fake player a short warmup so the baseline (non-critical) hit is not under-scaled.
|
||||
delayTicks(6)
|
||||
}
|
||||
val base = Location(attacker.world, 0.0, 100.0, 0.0)
|
||||
runSync {
|
||||
attacker.teleport(base)
|
||||
attacker.velocity = Vector(0.0, 0.0, 0.0)
|
||||
attacker.isSprinting = false
|
||||
attacker.fallDistance = 0f
|
||||
attacker.updateInventory()
|
||||
}
|
||||
|
||||
if (critical) {
|
||||
// Give the server one tick to recognise the falling state, then re-apply immediately before the swing
|
||||
// so it does not get cleared by ticking (varies by version / fake player internals).
|
||||
runSync {
|
||||
attacker.isSprinting = true
|
||||
attacker.teleport(attacker.location.add(0.0, 1.0, 0.0))
|
||||
attacker.velocity = Vector(0.0, -0.1, 0.0)
|
||||
attacker.fallDistance = 2f
|
||||
setOnGround(attacker, false)
|
||||
}
|
||||
delayTicks(1)
|
||||
runSync {
|
||||
attacker.isSprinting = true
|
||||
attacker.velocity = Vector(0.0, -0.1, 0.0)
|
||||
attacker.fallDistance = 2f
|
||||
setOnGround(attacker, false)
|
||||
if (!DamageUtils.isCriticalHit1_8(attacker)) {
|
||||
attacker.fallDistance = 3f
|
||||
setOnGround(attacker, false)
|
||||
}
|
||||
val loc = attacker.location
|
||||
val dir = victim.location.toVector().subtract(loc.toVector()).normalize()
|
||||
loc.direction = dir
|
||||
attacker.teleport(loc)
|
||||
testPlugin.logger.info(
|
||||
"Critical pre-attack: fallDistance=${attacker.fallDistance} onGround=${attacker.isOnGround}"
|
||||
)
|
||||
attacker.updateInventory()
|
||||
attackCompat(attacker, victim)
|
||||
}
|
||||
} else {
|
||||
runSync {
|
||||
attacker.isSprinting = false
|
||||
attacker.fallDistance = 0f
|
||||
attacker.velocity = Vector(0.0, 0.0, 0.0)
|
||||
val loc = attacker.location
|
||||
val dir = victim.location.toVector().subtract(loc.toVector()).normalize()
|
||||
loc.direction = dir
|
||||
attacker.teleport(loc)
|
||||
testPlugin.logger.info(
|
||||
"Normal pre-attack: fallDistance=${attacker.fallDistance} onGround=${attacker.isOnGround}"
|
||||
)
|
||||
attacker.updateInventory()
|
||||
attackCompat(attacker, victim)
|
||||
}
|
||||
}
|
||||
delayTicks(4)
|
||||
events.firstOrNull()?.damage?.let { return it }
|
||||
if (critical) {
|
||||
val ocmEvent = ocmEvents.lastOrNull()
|
||||
if (ocmEvent != null && !ocmEvent.was1_8Crit()) {
|
||||
testPlugin.logger.info(
|
||||
"Critical path but was1_8Crit=false; fallDistance=${attacker.fallDistance} " +
|
||||
"onGround=${attacker.isOnGround}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLegacy) {
|
||||
// As a last resort on legacy, drive damage via Bukkit API to ensure EDBE fires.
|
||||
runSync {
|
||||
val base = WeaponDamages.getDamage(weapon.type).takeIf { it > 0 } ?: 1.0
|
||||
victim.damage(base, attacker)
|
||||
}
|
||||
delayTicks(4)
|
||||
events.firstOrNull()?.damage?.let { return it }
|
||||
val healthDelta = (victim.maxHealth - victim.health).toDouble()
|
||||
if (healthDelta > 0.0) return healthDelta
|
||||
}
|
||||
|
||||
error("Expected a damage event for critical=$critical")
|
||||
} finally {
|
||||
HandlerList.unregisterAll(listener)
|
||||
runSync {
|
||||
victim.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun TestScope.withConfig(block: suspend TestScope.() -> Unit) {
|
||||
val critEnabled = ocm.config.getBoolean("old-critical-hits.enabled")
|
||||
val critMultiplier = ocm.config.getDouble("old-critical-hits.multiplier")
|
||||
val critAllowSprinting = ocm.config.getBoolean("old-critical-hits.allow-sprinting")
|
||||
val damagesSection = ocm.config.getConfigurationSection("old-tool-damage.damages")
|
||||
val damagesSnapshot = damagesSection?.getKeys(false)?.associateWith { damagesSection.get(it) } ?: emptyMap()
|
||||
|
||||
fun reloadDamageModules() {
|
||||
WeaponDamages.initialise(ocm)
|
||||
criticalModule.reload()
|
||||
toolDamageModule.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
}
|
||||
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
ocm.config.set("old-critical-hits.enabled", critEnabled)
|
||||
ocm.config.set("old-critical-hits.multiplier", critMultiplier)
|
||||
ocm.config.set("old-critical-hits.allow-sprinting", critAllowSprinting)
|
||||
damagesSnapshot.forEach { (key, value) ->
|
||||
ocm.config.set("old-tool-damage.damages.$key", value)
|
||||
}
|
||||
reloadDamageModules()
|
||||
}
|
||||
}
|
||||
|
||||
beforeSpec {
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val location = Location(world, 0.0, 100.0, 0.0)
|
||||
fakeAttacker = FakePlayer(testPlugin)
|
||||
fakeAttacker.spawn(location)
|
||||
attacker = checkNotNull(Bukkit.getPlayer(fakeAttacker.uuid))
|
||||
attacker.gameMode = GameMode.SURVIVAL
|
||||
attacker.isInvulnerable = false
|
||||
attacker.inventory.clear()
|
||||
attacker.activePotionEffects.forEach { attacker.removePotionEffect(it.type) }
|
||||
attacker.isOp = true
|
||||
val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(attacker.uniqueId)
|
||||
playerData.setModesetForWorld(attacker.world.uid, "old")
|
||||
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(attacker.uniqueId, playerData)
|
||||
}
|
||||
}
|
||||
|
||||
afterSpec {
|
||||
runSync {
|
||||
fakeAttacker.removePlayer()
|
||||
}
|
||||
}
|
||||
|
||||
test("critical hit multiplier applies to customised tool damage") {
|
||||
withConfig {
|
||||
ocm.config.set("old-critical-hits.enabled", true)
|
||||
ocm.config.set("old-critical-hits.multiplier", 1.5)
|
||||
ocm.config.set("old-critical-hits.allow-sprinting", true)
|
||||
ocm.config.set("old-tool-damage.damages.STONE_SWORD", 10)
|
||||
WeaponDamages.initialise(ocm)
|
||||
criticalModule.reload()
|
||||
toolDamageModule.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
|
||||
val stoneSword = XMaterial.STONE_SWORD.parseItem()
|
||||
?: error("STONE_SWORD material not available")
|
||||
val normalDamage = hitAndCaptureDamage(stoneSword, critical = false)
|
||||
val criticalDamage = hitAndCaptureDamage(stoneSword, critical = true)
|
||||
withClue("normal=$normalDamage critical=$criticalDamage") {
|
||||
(criticalDamage / normalDamage) shouldBe (1.5 plusOrMinus 0.05)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("critical hit multiplier applies when tool damage matches vanilla values") {
|
||||
withConfig {
|
||||
ocm.config.set("old-critical-hits.enabled", true)
|
||||
ocm.config.set("old-critical-hits.multiplier", 1.5)
|
||||
ocm.config.set("old-critical-hits.allow-sprinting", true)
|
||||
ocm.config.set("old-tool-damage.damages.IRON_SWORD", 6)
|
||||
WeaponDamages.initialise(ocm)
|
||||
criticalModule.reload()
|
||||
toolDamageModule.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
|
||||
val ironSword = XMaterial.IRON_SWORD.parseItem()
|
||||
?: error("IRON_SWORD material not available")
|
||||
val normalDamage = hitAndCaptureDamage(ironSword, critical = false)
|
||||
val criticalDamage = hitAndCaptureDamage(ironSword, critical = true)
|
||||
withClue("normal=$normalDamage critical=$criticalDamage") {
|
||||
(criticalDamage / normalDamage) shouldBe (1.5 plusOrMinus 0.05)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("critical hit multiplier applies to config damage for iron axe") {
|
||||
withConfig {
|
||||
ocm.config.set("old-critical-hits.enabled", true)
|
||||
ocm.config.set("old-critical-hits.multiplier", 1.5)
|
||||
ocm.config.set("old-critical-hits.allow-sprinting", true)
|
||||
ocm.config.set("old-tool-damage.damages.IRON_AXE", 6)
|
||||
WeaponDamages.initialise(ocm)
|
||||
criticalModule.reload()
|
||||
toolDamageModule.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
|
||||
val ironAxe = XMaterial.IRON_AXE.parseItem()
|
||||
?: error("IRON_AXE material not available")
|
||||
val normalDamage = hitAndCaptureDamage(ironAxe, critical = false)
|
||||
val criticalDamage = hitAndCaptureDamage(ironAxe, critical = true)
|
||||
testPlugin.logger.info("Crit debug (cfg=6): normal=$normalDamage critical=$criticalDamage")
|
||||
withClue("normal=$normalDamage critical=$criticalDamage") {
|
||||
(criticalDamage / normalDamage) shouldBe (1.5 plusOrMinus 0.05)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("critical hit multiplies configured iron axe damage") {
|
||||
withConfig {
|
||||
ocm.config.set("old-critical-hits.enabled", true)
|
||||
ocm.config.set("old-critical-hits.multiplier", 1.25)
|
||||
ocm.config.set("old-critical-hits.allow-sprinting", true)
|
||||
ocm.config.set("old-tool-damage.damages.IRON_AXE", 4.5)
|
||||
WeaponDamages.initialise(ocm)
|
||||
criticalModule.reload()
|
||||
toolDamageModule.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
|
||||
val ironAxe = XMaterial.IRON_AXE.parseItem()
|
||||
?: error("IRON_AXE material not available")
|
||||
val normalDamage = hitAndCaptureDamage(ironAxe, critical = false)
|
||||
val criticalDamage = hitAndCaptureDamage(ironAxe, critical = true)
|
||||
testPlugin.logger.info("Crit debug (cfg=4.5): normal=$normalDamage critical=$criticalDamage")
|
||||
withClue("normal=$normalDamage critical=$criticalDamage") {
|
||||
normalDamage shouldBe (4.5 plusOrMinus 0.05)
|
||||
criticalDamage shouldBe (5.625 plusOrMinus 0.05)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.core.test.TestScope
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.Config
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.damage.NewWeaponDamage
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.damage.OCMEntityDamageByEntityEvent
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
|
||||
import kotlinx.coroutines.delay
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.entity.Entity
|
||||
import org.bukkit.entity.LivingEntity
|
||||
import org.bukkit.entity.Villager
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.EventPriority
|
||||
import org.bukkit.event.HandlerList
|
||||
import org.bukkit.event.Listener
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.lang.reflect.Method
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class OldToolDamageMobIntegrationTest :
|
||||
FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
fun runSync(action: () -> Unit) {
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit
|
||||
.getScheduler()
|
||||
.callSyncMethod(
|
||||
testPlugin,
|
||||
Callable {
|
||||
action()
|
||||
null
|
||||
},
|
||||
).get()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delayTicks(ticks: Long) {
|
||||
delay(ticks * 50L)
|
||||
}
|
||||
|
||||
suspend fun TestScope.withConfig(block: suspend TestScope.() -> Unit) {
|
||||
val damagesSection = ocm.config.getConfigurationSection("old-tool-damage.damages")
|
||||
val damagesSnapshot = damagesSection?.getKeys(false)?.associateWith { damagesSection.get(it) } ?: emptyMap()
|
||||
val disabledModules = ocm.config.getStringList("disabled_modules")
|
||||
val modesetsSection =
|
||||
ocm.config.getConfigurationSection("modesets")
|
||||
?: error("Missing 'modesets' section in config")
|
||||
val modesetSnapshot =
|
||||
modesetsSection.getKeys(false).associateWith { key ->
|
||||
ocm.config.getStringList("modesets.$key")
|
||||
}
|
||||
|
||||
fun reloadAll() {
|
||||
ocm.saveConfig()
|
||||
Config.reload()
|
||||
}
|
||||
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
damagesSnapshot.forEach { (key, value) ->
|
||||
ocm.config.set("old-tool-damage.damages.$key", value)
|
||||
}
|
||||
ocm.config.set("disabled_modules", disabledModules)
|
||||
modesetSnapshot.forEach { (key, list) ->
|
||||
ocm.config.set("modesets.$key", list)
|
||||
}
|
||||
reloadAll()
|
||||
}
|
||||
}
|
||||
|
||||
fun mobMethodSignature(method: Method): String {
|
||||
val params = method.parameterTypes.joinToString(",") { it.name }
|
||||
return "${method.declaringClass.name}#${method.name}($params):${method.returnType.name}"
|
||||
}
|
||||
|
||||
fun mobCollectAllMethods(start: Class<*>): List<Method> {
|
||||
val methods = LinkedHashMap<String, Method>()
|
||||
var current: Class<*>? = start
|
||||
while (current != null) {
|
||||
current.declaredMethods.forEach { method ->
|
||||
methods.putIfAbsent(mobMethodSignature(method), method)
|
||||
}
|
||||
current = current.superclass
|
||||
}
|
||||
start.methods.forEach { method ->
|
||||
methods.putIfAbsent(mobMethodSignature(method), method)
|
||||
}
|
||||
return methods.values.toList()
|
||||
}
|
||||
|
||||
fun mobScoreAttackMethod(method: Method): Int {
|
||||
var score = 0
|
||||
if (method.name == "attack") score += 100
|
||||
if (method.name == "a") score += 80
|
||||
val param = method.parameterTypes[0]
|
||||
if (param.simpleName == "Entity") score += 40
|
||||
if (param.simpleName.contains("Entity")) score += 10
|
||||
if (method.returnType == Void.TYPE) score += 10
|
||||
if (method.returnType == java.lang.Boolean.TYPE) score += 8
|
||||
val declaring = method.declaringClass.simpleName
|
||||
if (declaring.contains("EntityInsentient")) score += 20
|
||||
if (declaring.contains("Mob")) score += 10
|
||||
return score
|
||||
}
|
||||
|
||||
fun resolveDebugFile(): java.io.File {
|
||||
val versionTag = Bukkit.getBukkitVersion().replace(Regex("[^A-Za-z0-9_.-]"), "_")
|
||||
val runDir = java.io.File(System.getProperty("user.dir"))
|
||||
val repoRoot = runDir.parentFile?.parentFile ?: runDir
|
||||
return java.io.File(repoRoot, "build/mob-tool-damage-debug-$versionTag.txt")
|
||||
}
|
||||
|
||||
fun attackCompat(
|
||||
attacker: LivingEntity,
|
||||
target: Entity,
|
||||
): Boolean {
|
||||
val handleMethod =
|
||||
attacker.javaClass.methods.firstOrNull { method ->
|
||||
method.name == "getHandle" && method.parameterTypes.isEmpty()
|
||||
} ?: error("Failed to resolve CraftEntity#getHandle for ${attacker.javaClass.name}")
|
||||
|
||||
val attackerHandle =
|
||||
handleMethod.invoke(attacker)
|
||||
?: error("CraftEntity#getHandle returned null for ${attacker.javaClass.name}")
|
||||
|
||||
val targetHandle =
|
||||
target.javaClass.methods
|
||||
.firstOrNull { method ->
|
||||
method.name == "getHandle" && method.parameterTypes.isEmpty()
|
||||
}?.invoke(target) ?: error("Failed to resolve CraftEntity#getHandle for ${target.javaClass.name}")
|
||||
|
||||
val candidates =
|
||||
listOfNotNull(
|
||||
Reflector.getMethodAssignable(attackerHandle.javaClass, "attack", targetHandle.javaClass),
|
||||
Reflector.getMethodAssignable(attackerHandle.javaClass, "a", targetHandle.javaClass),
|
||||
).ifEmpty {
|
||||
mobCollectAllMethods(attackerHandle.javaClass)
|
||||
.asSequence()
|
||||
.filter { it.parameterCount == 1 }
|
||||
.filter { it.parameterTypes[0].isAssignableFrom(targetHandle.javaClass) }
|
||||
.filter { it.returnType == Void.TYPE || it.returnType == java.lang.Boolean.TYPE }
|
||||
.sortedByDescending { mobScoreAttackMethod(it) }
|
||||
.toList()
|
||||
}
|
||||
|
||||
candidates.forEach { it.isAccessible = true }
|
||||
for (method in candidates) {
|
||||
try {
|
||||
val result = method.invoke(attackerHandle, targetHandle)
|
||||
if (result is Boolean && !result) {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
} catch (ignored: Exception) {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun captureVindicatorBaseDamage(
|
||||
debugFile: java.io.File,
|
||||
label: String,
|
||||
): Double {
|
||||
val mobClass: Class<out LivingEntity> =
|
||||
try {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
Class.forName("org.bukkit.entity.Vindicator") as Class<out LivingEntity>
|
||||
} catch (_: ClassNotFoundException) {
|
||||
org.bukkit.entity.Zombie::class.java
|
||||
}
|
||||
|
||||
lateinit var victim: Villager
|
||||
lateinit var mob: LivingEntity
|
||||
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getWorld("world"))
|
||||
val victimLocation = Location(world, 0.0, 100.0, 0.0)
|
||||
val mobLocation = Location(world, 1.1, 100.0, 0.0)
|
||||
|
||||
victim = world.spawn(victimLocation, Villager::class.java)
|
||||
victim.isInvulnerable = false
|
||||
victim.health = victim.maxHealth
|
||||
victim.maximumNoDamageTicks = 0
|
||||
victim.noDamageTicks = 0
|
||||
|
||||
mob = world.spawn(mobLocation, mobClass)
|
||||
mob.isSilent = true
|
||||
mob.equipment?.setItemInMainHand(ItemStack(Material.IRON_AXE))
|
||||
mob.maximumNoDamageTicks = 0
|
||||
mob.noDamageTicks = 0
|
||||
}
|
||||
|
||||
try {
|
||||
val baseDamage = NewWeaponDamage.getDamage(Material.IRON_AXE) // vanilla 1.9 base
|
||||
val event =
|
||||
EntityDamageByEntityEvent(
|
||||
mob,
|
||||
victim,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
baseDamage.toDouble(),
|
||||
)
|
||||
runSync {
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
}
|
||||
val moduleEnabled = Config.moduleEnabled("old-tool-damage", victim.world)
|
||||
debugFile.parentFile?.mkdirs()
|
||||
debugFile.appendText(
|
||||
"label=$label base=${event.damage} raw=${event.damage} weapon=IRON_AXE enabled=$moduleEnabled\n",
|
||||
)
|
||||
return event.damage
|
||||
} finally {
|
||||
runSync {
|
||||
mob.remove()
|
||||
victim.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("mob tool damage follows configured old-tool-damage values") {
|
||||
withConfig {
|
||||
val debugFile = resolveDebugFile()
|
||||
debugFile.parentFile?.mkdirs()
|
||||
debugFile.writeText("start\n")
|
||||
try {
|
||||
ocm.config.set(
|
||||
"disabled_modules",
|
||||
ocm.config
|
||||
.getStringList("disabled_modules")
|
||||
.filterNot { it.equals("old-tool-damage", true) },
|
||||
)
|
||||
val oldModeset =
|
||||
ocm.config
|
||||
.getStringList("modesets.old")
|
||||
.filterNot { it.equals("old-tool-damage", true) }
|
||||
.toMutableList()
|
||||
oldModeset.add("old-tool-damage")
|
||||
ocm.config.set("modesets.old", oldModeset)
|
||||
ocm.config.set("old-tool-damage.damages.IRON_AXE", 1)
|
||||
ocm.saveConfig()
|
||||
Config.reload()
|
||||
|
||||
val lowDamage =
|
||||
try {
|
||||
captureVindicatorBaseDamage(debugFile, "low")
|
||||
} catch (e: Throwable) {
|
||||
debugFile.appendText("low-error=${e::class.java.simpleName}: ${e.message}\n")
|
||||
throw e
|
||||
}
|
||||
|
||||
ocm.config.set("old-tool-damage.damages.IRON_AXE", 20)
|
||||
ocm.saveConfig()
|
||||
Config.reload()
|
||||
|
||||
val highDamage =
|
||||
try {
|
||||
captureVindicatorBaseDamage(debugFile, "high")
|
||||
} catch (e: Throwable) {
|
||||
debugFile.appendText("high-error=${e::class.java.simpleName}: ${e.message}\n")
|
||||
throw e
|
||||
}
|
||||
|
||||
val delta = highDamage - lowDamage
|
||||
debugFile.appendText("delta=$delta low=$lowDamage high=$highDamage\n")
|
||||
if (delta <= 10.0) {
|
||||
error("Mob tool damage delta too small: delta=$delta low=$lowDamage high=$highDamage")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
debugFile.appendText("test-error=${e::class.java.simpleName}: ${e.message}\n")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import com.github.retrooper.packetevents.PacketEvents
|
||||
import com.github.retrooper.packetevents.event.PacketSendEvent
|
||||
import com.github.retrooper.packetevents.manager.server.ServerVersion
|
||||
import com.github.retrooper.packetevents.netty.buffer.ByteBufHelper
|
||||
import com.github.retrooper.packetevents.protocol.ConnectionState
|
||||
import com.github.retrooper.packetevents.protocol.PacketSide
|
||||
import com.github.retrooper.packetevents.protocol.packettype.PacketType
|
||||
import com.github.retrooper.packetevents.protocol.packettype.PacketTypeCommon
|
||||
import com.github.retrooper.packetevents.protocol.particle.Particle
|
||||
import com.github.retrooper.packetevents.protocol.particle.type.ParticleTypes
|
||||
import com.github.retrooper.packetevents.protocol.sound.Sound
|
||||
import com.github.retrooper.packetevents.protocol.sound.SoundCategory
|
||||
import com.github.retrooper.packetevents.protocol.sound.Sounds
|
||||
import com.github.retrooper.packetevents.protocol.player.User
|
||||
import com.github.retrooper.packetevents.protocol.player.UserProfile
|
||||
import com.github.retrooper.packetevents.util.Vector3d
|
||||
import com.github.retrooper.packetevents.util.Vector3f
|
||||
import com.github.retrooper.packetevents.util.Vector3i
|
||||
import com.github.retrooper.packetevents.wrapper.PacketWrapper
|
||||
import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerParticle
|
||||
import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSoundEffect
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleAttackSounds
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleSwordSweepParticles
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.Config
|
||||
import kotlinx.coroutines.delay
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class PacketCancellationIntegrationTest : FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val ocm = Bukkit.getPluginManager().getPlugin("OldCombatMechanics") as OCMMain
|
||||
|
||||
fun <T> runSync(action: () -> T): T {
|
||||
return if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable { action() }).get()
|
||||
}
|
||||
}
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
beforeSpec {
|
||||
require(PacketEvents.getAPI().isInitialized) { "PacketEvents not initialised for integration tests." }
|
||||
}
|
||||
|
||||
suspend fun withModuleState(
|
||||
moduleName: String,
|
||||
enabled: Boolean,
|
||||
preReload: (() -> Unit)? = null,
|
||||
postRestore: (() -> Unit)? = null,
|
||||
block: suspend () -> Unit
|
||||
) {
|
||||
val disabledOriginal = ocm.config.getStringList("disabled_modules")
|
||||
val alwaysOriginal = ocm.config.getStringList("always_enabled_modules")
|
||||
val modesetsSection = ocm.config.getConfigurationSection("modesets")
|
||||
?: error("Missing modesets section in config")
|
||||
val modesetsOriginal = modesetsSection.getKeys(false).associateWith {
|
||||
ocm.config.getStringList("modesets.$it")
|
||||
}
|
||||
|
||||
fun List<String>.withoutModule(): MutableList<String> =
|
||||
filterNot { it.equals(moduleName, true) }.toMutableList()
|
||||
|
||||
val disabledUpdated = disabledOriginal.withoutModule()
|
||||
val alwaysUpdated = alwaysOriginal.withoutModule()
|
||||
if (enabled) {
|
||||
alwaysUpdated.add(moduleName)
|
||||
} else {
|
||||
disabledUpdated.add(moduleName)
|
||||
}
|
||||
|
||||
ocm.config.set("disabled_modules", disabledUpdated)
|
||||
ocm.config.set("always_enabled_modules", alwaysUpdated)
|
||||
modesetsOriginal.keys.forEach { key ->
|
||||
val filtered = modesetsOriginal.getValue(key).withoutModule()
|
||||
ocm.config.set("modesets.$key", filtered)
|
||||
}
|
||||
preReload?.invoke()
|
||||
ocm.saveConfig()
|
||||
Config.reload()
|
||||
delay(2 * 50L)
|
||||
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
ocm.config.set("disabled_modules", disabledOriginal)
|
||||
ocm.config.set("always_enabled_modules", alwaysOriginal)
|
||||
modesetsOriginal.forEach { (key, value) ->
|
||||
ocm.config.set("modesets.$key", value)
|
||||
}
|
||||
postRestore?.invoke()
|
||||
ocm.saveConfig()
|
||||
Config.reload()
|
||||
delay(2 * 50L)
|
||||
}
|
||||
}
|
||||
|
||||
fun spawnFakePlayer(): Pair<FakePlayer, Player> {
|
||||
val world = Bukkit.getWorld("world") ?: error("world not loaded")
|
||||
val location = Location(world, 0.0, 120.0, 0.0, 0f, 0f)
|
||||
val fake = FakePlayer(testPlugin)
|
||||
fake.spawn(location)
|
||||
val player = Bukkit.getPlayer(fake.uuid) ?: error("Player not found after spawn")
|
||||
return fake to player
|
||||
}
|
||||
|
||||
suspend fun removeFakePlayer(fake: FakePlayer) {
|
||||
runSync { fake.removePlayer() }
|
||||
delay(2 * 50L)
|
||||
}
|
||||
|
||||
suspend fun withBlockedSounds(module: ModuleAttackSounds, blocked: Set<String>, block: suspend () -> Unit) {
|
||||
val field = module.javaClass.getDeclaredField("blockedSounds")
|
||||
field.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val current = field.get(module) as MutableSet<String>
|
||||
val snapshot = current.toSet()
|
||||
current.clear()
|
||||
current.addAll(blocked)
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
current.clear()
|
||||
current.addAll(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
fun createPacketSendEvent(
|
||||
packetId: Int,
|
||||
packetType: PacketTypeCommon,
|
||||
serverVersion: ServerVersion,
|
||||
channel: Any,
|
||||
user: User,
|
||||
player: Player,
|
||||
buffer: Any
|
||||
): PacketSendEvent {
|
||||
val constructor = PacketSendEvent::class.java.getDeclaredConstructor(
|
||||
Int::class.javaPrimitiveType,
|
||||
PacketTypeCommon::class.java,
|
||||
ServerVersion::class.java,
|
||||
Any::class.java,
|
||||
User::class.java,
|
||||
Any::class.java,
|
||||
Any::class.java
|
||||
)
|
||||
constructor.isAccessible = true
|
||||
return constructor.newInstance(
|
||||
packetId,
|
||||
packetType,
|
||||
serverVersion,
|
||||
channel,
|
||||
user,
|
||||
player,
|
||||
buffer
|
||||
) as PacketSendEvent
|
||||
}
|
||||
|
||||
fun runPacketThroughPacketEvents(player: Player, wrapper: PacketWrapper<*>): Boolean {
|
||||
val api = PacketEvents.getAPI()
|
||||
val channel = api.playerManager.getChannel(player) ?: error("Missing channel for ${player.name}")
|
||||
val serverVersion = api.serverManager.version
|
||||
val user = User(
|
||||
channel,
|
||||
ConnectionState.PLAY,
|
||||
serverVersion.toClientVersion(),
|
||||
UserProfile(player.uniqueId, player.name)
|
||||
)
|
||||
user.setEncoderState(ConnectionState.PLAY)
|
||||
user.setDecoderState(ConnectionState.PLAY)
|
||||
user.setEntityId(player.entityId)
|
||||
|
||||
val buffers = api.protocolManager.transformWrappers(wrapper, channel, true)
|
||||
val buffer = buffers.firstOrNull() ?: error("No buffer produced for ${wrapper.javaClass.simpleName}")
|
||||
val packetId = ByteBufHelper.readVarInt(buffer)
|
||||
val packetType = PacketType.getById(PacketSide.SERVER, ConnectionState.PLAY, user.clientVersion, packetId)
|
||||
?: error("No packet type for id $packetId in ${user.clientVersion}")
|
||||
val event = createPacketSendEvent(packetId, packetType, serverVersion, channel, user, player, buffer)
|
||||
|
||||
api.eventManager.callEvent(event)
|
||||
|
||||
return event.isCancelled
|
||||
}
|
||||
|
||||
fun soundName(sound: Sound): String = sound.soundId.toString()
|
||||
|
||||
test("sweep particles are cancelled when module enabled") {
|
||||
withModuleState("disable-sword-sweep-particles", enabled = true) {
|
||||
val (fake, player) = runSync { spawnFakePlayer() }
|
||||
try {
|
||||
val particle = Particle(ParticleTypes.SWEEP_ATTACK)
|
||||
val position = Vector3d(player.location.x, player.location.y, player.location.z)
|
||||
val offset = Vector3f(0f, 0f, 0f)
|
||||
val wrapper = WrapperPlayServerParticle(particle, false, position, offset, 0.0f, 1)
|
||||
|
||||
val cancelled = runPacketThroughPacketEvents(player, wrapper)
|
||||
|
||||
cancelled shouldBe true
|
||||
} finally {
|
||||
removeFakePlayer(fake)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("sweep particles are not cancelled when module disabled") {
|
||||
withModuleState("disable-sword-sweep-particles", enabled = false) {
|
||||
val (fake, player) = runSync { spawnFakePlayer() }
|
||||
try {
|
||||
val particle = Particle(ParticleTypes.FLAME)
|
||||
val position = Vector3d(player.location.x, player.location.y, player.location.z)
|
||||
val offset = Vector3f(0f, 0f, 0f)
|
||||
val wrapper = WrapperPlayServerParticle(particle, false, position, offset, 0.0f, 1)
|
||||
|
||||
val cancelled = runPacketThroughPacketEvents(player, wrapper)
|
||||
|
||||
cancelled shouldBe false
|
||||
} finally {
|
||||
removeFakePlayer(fake)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("blocked attack sounds are cancelled when module enabled") {
|
||||
withModuleState("disable-attack-sounds", enabled = true) {
|
||||
val (fake, player) = runSync { spawnFakePlayer() }
|
||||
try {
|
||||
val module = ModuleLoader.getModules().filterIsInstance<ModuleAttackSounds>().firstOrNull()
|
||||
?: error("ModuleAttackSounds not registered")
|
||||
val sound = Sounds.ENTITY_PLAYER_ATTACK_STRONG
|
||||
val position = Vector3i(player.location.blockX, player.location.blockY, player.location.blockZ)
|
||||
val wrapper = WrapperPlayServerSoundEffect(sound, SoundCategory.PLAYER, position, 0.37f, 0.71f)
|
||||
|
||||
withBlockedSounds(module, setOf(soundName(sound))) {
|
||||
val cancelled = runPacketThroughPacketEvents(player, wrapper)
|
||||
cancelled shouldBe true
|
||||
}
|
||||
} finally {
|
||||
removeFakePlayer(fake)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("non-blocked attack sounds are not cancelled when module enabled") {
|
||||
withModuleState("disable-attack-sounds", enabled = true) {
|
||||
val (fake, player) = runSync { spawnFakePlayer() }
|
||||
try {
|
||||
val module = ModuleLoader.getModules().filterIsInstance<ModuleAttackSounds>().firstOrNull()
|
||||
?: error("ModuleAttackSounds not registered")
|
||||
val blockedSound = Sounds.ENTITY_PLAYER_ATTACK_STRONG
|
||||
val sound = Sounds.ENTITY_PLAYER_LEVELUP
|
||||
val position = Vector3i(player.location.blockX, player.location.blockY, player.location.blockZ)
|
||||
val wrapper = WrapperPlayServerSoundEffect(sound, SoundCategory.PLAYER, position, 0.43f, 0.21f)
|
||||
|
||||
withBlockedSounds(module, setOf(soundName(blockedSound))) {
|
||||
val cancelled = runPacketThroughPacketEvents(player, wrapper)
|
||||
cancelled shouldBe false
|
||||
}
|
||||
} finally {
|
||||
removeFakePlayer(fake)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.doubles.shouldBeLessThan
|
||||
import io.kotest.matchers.doubles.shouldBeGreaterThan
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleSwordBlocking
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
|
||||
import kotlinx.coroutines.delay
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.GameMode
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.EventPriority
|
||||
import org.bukkit.event.block.Action
|
||||
import org.bukkit.event.entity.EntityDamageEvent
|
||||
import org.bukkit.event.player.PlayerInteractEvent
|
||||
import org.bukkit.inventory.EquipmentSlot
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class PaperSwordBlockingDamageReductionIntegrationTest : FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
fun <T> runSync(action: () -> T): T {
|
||||
return if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler()
|
||||
.callSyncMethod(testPlugin, Callable { action() })
|
||||
.get()
|
||||
}
|
||||
|
||||
suspend fun delayTicks(ticks: Long) {
|
||||
delay(ticks * 50L)
|
||||
}
|
||||
|
||||
fun paperDataComponentApiPresent(): Boolean {
|
||||
return try {
|
||||
Class.forName("io.papermc.paper.datacomponent.DataComponentTypes")
|
||||
true
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun setModeset(player: Player, name: String) {
|
||||
val data = getPlayerData(player.uniqueId)
|
||||
data.setModesetForWorld(player.world.uid, name)
|
||||
setPlayerData(player.uniqueId, data)
|
||||
}
|
||||
|
||||
fun equipSword(player: Player, material: Material) {
|
||||
player.inventory.setItemInMainHand(ItemStack(material))
|
||||
player.updateInventory()
|
||||
}
|
||||
|
||||
fun rightClickMainHand(player: Player) {
|
||||
val event = PlayerInteractEvent(
|
||||
player,
|
||||
Action.RIGHT_CLICK_AIR,
|
||||
player.inventory.itemInMainHand,
|
||||
null,
|
||||
org.bukkit.block.BlockFace.SELF,
|
||||
EquipmentSlot.HAND
|
||||
)
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
}
|
||||
|
||||
lateinit var defenderFake: FakePlayer
|
||||
lateinit var defender: Player
|
||||
lateinit var module: ModuleSwordBlocking
|
||||
|
||||
beforeSpec {
|
||||
runSync {
|
||||
module = ModuleLoader.getModules().filterIsInstance<ModuleSwordBlocking>().firstOrNull()
|
||||
?: error("ModuleSwordBlocking not registered")
|
||||
|
||||
val world = Bukkit.getWorld("world") ?: error("world missing")
|
||||
val base = Location(world, 0.0, 100.0, 0.0)
|
||||
|
||||
defenderFake = FakePlayer(testPlugin)
|
||||
defenderFake.spawn(base)
|
||||
defender = Bukkit.getPlayer(defenderFake.uuid) ?: error("defender not found")
|
||||
|
||||
defender.gameMode = GameMode.SURVIVAL
|
||||
defender.maximumNoDamageTicks = 20
|
||||
defender.noDamageTicks = 0
|
||||
defender.isInvulnerable = false
|
||||
defender.inventory.clear()
|
||||
setModeset(defender, "old")
|
||||
}
|
||||
}
|
||||
|
||||
afterSpec {
|
||||
runSync {
|
||||
defenderFake.removePlayer()
|
||||
}
|
||||
}
|
||||
|
||||
test("Paper sword blocking sets BLOCKING modifier negative on hit") {
|
||||
if (!paperDataComponentApiPresent()) {
|
||||
println("Skipping: Paper DataComponent API not present")
|
||||
return@test
|
||||
}
|
||||
|
||||
runSync {
|
||||
equipSword(defender, Material.DIAMOND_SWORD)
|
||||
defender.inventory.setItemInOffHand(ItemStack(Material.AIR))
|
||||
}
|
||||
|
||||
runSync { rightClickMainHand(defender) }
|
||||
delayTicks(2)
|
||||
|
||||
val offhandAfter = runSync { defender.inventory.itemInOffHand.type }
|
||||
if (offhandAfter == Material.SHIELD) {
|
||||
println("Skipping: legacy shield swap path is active on this server")
|
||||
return@test
|
||||
}
|
||||
|
||||
runSync {
|
||||
// The bug we saw in live logs: the sword can animate as BLOCK, but the server may not recognise
|
||||
// it as "blocking" for damage reduction (BLOCKING stays 0). This asserts the Paper path is
|
||||
// actually recognised server-side.
|
||||
module.isPaperSwordBlocking(defender) shouldBe true
|
||||
}
|
||||
|
||||
val zombie = runSync {
|
||||
defender.world.spawn(defender.location.clone().add(0.0, 0.0, 1.0), org.bukkit.entity.Zombie::class.java)
|
||||
}
|
||||
try {
|
||||
// Use a synthetic event here. We specifically care about the Paper sword-block detection and the
|
||||
// computed reduction. Whether Bukkit considers the BLOCKING modifier "applicable" is decided by
|
||||
// the server's internal damage pipeline and can differ for synthetic events vs real hits.
|
||||
val event = runSync {
|
||||
org.bukkit.event.entity.EntityDamageByEntityEvent(
|
||||
zombie,
|
||||
defender,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
2.5
|
||||
)
|
||||
}
|
||||
|
||||
val reduction = runSync { module.applyPaperBlockingReduction(event, 2.5) }
|
||||
reduction shouldBeGreaterThan 0.0
|
||||
// Also sanity-check sign: the module is supposed to write BLOCKING as a negative modifier downstream.
|
||||
// (We don't assert it here because synthetic events may not expose/apply that modifier consistently.)
|
||||
reduction shouldBeLessThan 2.5
|
||||
} finally {
|
||||
runSync {
|
||||
zombie.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.core.test.TestScope
|
||||
import io.kotest.matchers.doubles.plusOrMinus
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModulePlayerKnockback
|
||||
import com.cryptomorin.xseries.XAttribute
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.CompatibilityCapabilities
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.Material
|
||||
import org.bukkit.attribute.AttributeModifier
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent
|
||||
import org.bukkit.event.entity.EntityDamageEvent
|
||||
import org.bukkit.event.player.PlayerVelocityEvent
|
||||
import org.bukkit.inventory.ItemStack
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import org.bukkit.util.Vector
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class PlayerKnockbackIntegrationTest : FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
val module = ModuleLoader.getModules()
|
||||
.filterIsInstance<ModulePlayerKnockback>()
|
||||
.firstOrNull() ?: error("ModulePlayerKnockback not registered")
|
||||
|
||||
lateinit var attacker: Player
|
||||
lateinit var victim: Player
|
||||
lateinit var fakeAttacker: FakePlayer
|
||||
lateinit var fakeVictim: FakePlayer
|
||||
|
||||
fun runSync(action: () -> Unit) {
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
|
||||
action()
|
||||
null
|
||||
}).get()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun TestScope.withConfig(block: suspend TestScope.() -> Unit) {
|
||||
val horizontal = ocm.config.getDouble("old-player-knockback.knockback-horizontal")
|
||||
val vertical = ocm.config.getDouble("old-player-knockback.knockback-vertical")
|
||||
val verticalLimit = ocm.config.getDouble("old-player-knockback.knockback-vertical-limit")
|
||||
val extraHorizontal = ocm.config.getDouble("old-player-knockback.knockback-extra-horizontal")
|
||||
val extraVertical = ocm.config.getDouble("old-player-knockback.knockback-extra-vertical")
|
||||
val resistanceEnabled = ocm.config.getBoolean("old-player-knockback.enable-knockback-resistance")
|
||||
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
ocm.config.set("old-player-knockback.knockback-horizontal", horizontal)
|
||||
ocm.config.set("old-player-knockback.knockback-vertical", vertical)
|
||||
ocm.config.set("old-player-knockback.knockback-vertical-limit", verticalLimit)
|
||||
ocm.config.set("old-player-knockback.knockback-extra-horizontal", extraHorizontal)
|
||||
ocm.config.set("old-player-knockback.knockback-extra-vertical", extraVertical)
|
||||
ocm.config.set("old-player-knockback.enable-knockback-resistance", resistanceEnabled)
|
||||
module.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
}
|
||||
}
|
||||
|
||||
fun setModeset(player: Player, modeset: String) {
|
||||
val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
|
||||
playerData.setModesetForWorld(player.world.uid, modeset)
|
||||
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)
|
||||
}
|
||||
|
||||
fun pendingKnockbackField(): java.lang.reflect.Field {
|
||||
val names = listOf("pendingKnockback", "playerKnockbackHashMap")
|
||||
for (name in names) {
|
||||
val f = runCatching { ModulePlayerKnockback::class.java.getDeclaredField(name) }.getOrNull() ?: continue
|
||||
f.isAccessible = true
|
||||
return f
|
||||
}
|
||||
error("No pending knockback field found on ModulePlayerKnockback (tried: $names)")
|
||||
}
|
||||
|
||||
fun pendingKnockbackMap(): MutableMap<UUID, Any> {
|
||||
val field = pendingKnockbackField()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return field.get(module) as MutableMap<UUID, Any>
|
||||
}
|
||||
|
||||
fun getPendingVector(uuid: UUID): Vector? {
|
||||
val map = pendingKnockbackMap()
|
||||
val value = map[uuid] ?: return null
|
||||
return when (value) {
|
||||
is Vector -> value
|
||||
else -> {
|
||||
val vf = value.javaClass.getDeclaredField("velocity")
|
||||
vf.isAccessible = true
|
||||
vf.get(value) as? Vector
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removePending(uuid: UUID) {
|
||||
pendingKnockbackMap().remove(uuid)
|
||||
}
|
||||
|
||||
fun putPending(uuid: UUID, vector: Vector) {
|
||||
val fieldName = pendingKnockbackField().name
|
||||
if (fieldName == "playerKnockbackHashMap") {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(pendingKnockbackMap() as MutableMap<UUID, Vector>)[uuid] = vector
|
||||
return
|
||||
}
|
||||
|
||||
val pendingClass = ModulePlayerKnockback::class.java.declaredClasses
|
||||
.firstOrNull { it.simpleName == "PendingKnockback" }
|
||||
?: error("PendingKnockback inner class not found")
|
||||
val ctor = pendingClass.declaredConstructors.firstOrNull { constructor ->
|
||||
val parameterTypes = constructor.parameterTypes.toList()
|
||||
parameterTypes == listOf(Vector::class.java, UUID::class.java, Long::class.javaPrimitiveType) ||
|
||||
parameterTypes == listOf(Vector::class.java, Long::class.javaPrimitiveType)
|
||||
} ?: error("No supported PendingKnockback constructor found")
|
||||
ctor.isAccessible = true
|
||||
val pending = when (ctor.parameterTypes.size) {
|
||||
3 -> ctor.newInstance(vector, attacker.uniqueId, Long.MAX_VALUE)
|
||||
else -> ctor.newInstance(vector, Long.MAX_VALUE)
|
||||
}
|
||||
pendingKnockbackMap()[uuid] = pending
|
||||
}
|
||||
|
||||
fun damageEvent(): EntityDamageByEntityEvent {
|
||||
val event = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, 4.0)
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
fun velocityEvent(initial: Vector): PlayerVelocityEvent {
|
||||
val event = PlayerVelocityEvent(victim, initial)
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
beforeSpec {
|
||||
runSync {
|
||||
val world = Bukkit.getServer().getWorld("world")
|
||||
val attackerLocation = Location(world, 0.0, 100.0, 0.0, 0f, 0f)
|
||||
val victimLocation = Location(world, 1.0, 100.0, 0.0, 0f, 0f)
|
||||
|
||||
fakeAttacker = FakePlayer(testPlugin)
|
||||
fakeVictim = FakePlayer(testPlugin)
|
||||
fakeAttacker.spawn(attackerLocation)
|
||||
fakeVictim.spawn(victimLocation)
|
||||
|
||||
attacker = checkNotNull(Bukkit.getPlayer(fakeAttacker.uuid))
|
||||
victim = checkNotNull(Bukkit.getPlayer(fakeVictim.uuid))
|
||||
attacker.isOp = true
|
||||
victim.isOp = true
|
||||
attacker.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
|
||||
setModeset(attacker, "old")
|
||||
setModeset(victim, "old")
|
||||
}
|
||||
}
|
||||
|
||||
afterSpec {
|
||||
runSync {
|
||||
fakeAttacker.removePlayer()
|
||||
fakeVictim.removePlayer()
|
||||
}
|
||||
}
|
||||
|
||||
beforeTest {
|
||||
runSync {
|
||||
val world = Bukkit.getServer().getWorld("world")
|
||||
val attackerLocation = Location(world, 0.0, 100.0, 0.0, 0f, 0f)
|
||||
val victimLocation = Location(world, 1.0, 100.0, 0.0, 0f, 0f)
|
||||
|
||||
attacker.teleport(attackerLocation)
|
||||
victim.teleport(victimLocation)
|
||||
attacker.isSprinting = false
|
||||
attacker.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
|
||||
attacker.velocity = Vector(0, 0, 0)
|
||||
victim.velocity = Vector(0, 0, 0)
|
||||
victim.noDamageTicks = 0
|
||||
victim.maximumNoDamageTicks = 0
|
||||
victim.isInvulnerable = false
|
||||
setModeset(attacker, "old")
|
||||
setModeset(victim, "old")
|
||||
module.reload()
|
||||
}
|
||||
}
|
||||
|
||||
context("Knockback vectors") {
|
||||
test("base knockback is applied on velocity event") {
|
||||
withConfig {
|
||||
ocm.config.set("old-player-knockback.knockback-horizontal", 0.4)
|
||||
ocm.config.set("old-player-knockback.knockback-vertical", 0.4)
|
||||
ocm.config.set("old-player-knockback.knockback-vertical-limit", 0.4)
|
||||
ocm.config.set("old-player-knockback.knockback-extra-horizontal", 0.5)
|
||||
ocm.config.set("old-player-knockback.knockback-extra-vertical", 0.1)
|
||||
ocm.config.set("old-player-knockback.enable-knockback-resistance", false)
|
||||
module.reload()
|
||||
|
||||
damageEvent()
|
||||
val vector = getPendingVector(victim.uniqueId) ?: error("No knockback stored")
|
||||
vector.x shouldBe (0.4 plusOrMinus 0.0001)
|
||||
vector.y shouldBe (0.4 plusOrMinus 0.0001)
|
||||
vector.z shouldBe (0.0 plusOrMinus 0.0001)
|
||||
removePending(victim.uniqueId)
|
||||
}
|
||||
}
|
||||
|
||||
test("sprint adds extra knockback") {
|
||||
withConfig {
|
||||
ocm.config.set("old-player-knockback.knockback-horizontal", 0.4)
|
||||
ocm.config.set("old-player-knockback.knockback-vertical", 0.4)
|
||||
ocm.config.set("old-player-knockback.knockback-vertical-limit", 0.4)
|
||||
ocm.config.set("old-player-knockback.knockback-extra-horizontal", 0.5)
|
||||
ocm.config.set("old-player-knockback.knockback-extra-vertical", 0.1)
|
||||
ocm.config.set("old-player-knockback.enable-knockback-resistance", false)
|
||||
module.reload()
|
||||
|
||||
attacker.isSprinting = true
|
||||
attacker.teleport(attacker.location.apply { yaw = 0f })
|
||||
|
||||
val event = EntityDamageByEntityEvent(
|
||||
attacker,
|
||||
victim,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
4.0
|
||||
)
|
||||
module.onEntityDamageEntity(event)
|
||||
val vector = getPendingVector(victim.uniqueId) ?: error("No knockback stored")
|
||||
vector.x shouldBe (0.4 plusOrMinus 0.0001)
|
||||
vector.y shouldBe (0.5 plusOrMinus 0.0001)
|
||||
vector.z shouldBe (0.5 plusOrMinus 0.0001)
|
||||
removePending(victim.uniqueId)
|
||||
}
|
||||
}
|
||||
|
||||
test("velocity override only applies once") {
|
||||
withConfig {
|
||||
ocm.config.set("old-player-knockback.knockback-horizontal", 0.4)
|
||||
ocm.config.set("old-player-knockback.knockback-vertical", 0.4)
|
||||
ocm.config.set("old-player-knockback.knockback-vertical-limit", 0.4)
|
||||
ocm.config.set("old-player-knockback.knockback-extra-horizontal", 0.5)
|
||||
ocm.config.set("old-player-knockback.knockback-extra-vertical", 0.1)
|
||||
ocm.config.set("old-player-knockback.enable-knockback-resistance", false)
|
||||
module.reload()
|
||||
|
||||
val expected = Vector(0.4, 0.4, 0.0)
|
||||
putPending(victim.uniqueId, expected)
|
||||
val first = velocityEvent(Vector(0, 0, 0))
|
||||
first.velocity.x shouldBe (0.4 plusOrMinus 0.0001)
|
||||
|
||||
val secondInitial = Vector(1.0, 2.0, 3.0)
|
||||
val second = velocityEvent(secondInitial)
|
||||
second.velocity.x shouldBe (1.0 plusOrMinus 0.0001)
|
||||
second.velocity.y shouldBe (2.0 plusOrMinus 0.0001)
|
||||
second.velocity.z shouldBe (3.0 plusOrMinus 0.0001)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context("Knockback resistance") {
|
||||
test("modifiers are removed when resistance disabled") {
|
||||
withConfig {
|
||||
ocm.config.set("old-player-knockback.enable-knockback-resistance", false)
|
||||
module.reload()
|
||||
|
||||
val attributeType = XAttribute.KNOCKBACK_RESISTANCE.get() ?: return@withConfig
|
||||
val attribute = victim.getAttribute(attributeType)
|
||||
val modifier = AttributeModifier(UUID.randomUUID(), "test", 0.5, AttributeModifier.Operation.ADD_NUMBER)
|
||||
attribute?.addModifier(modifier)
|
||||
|
||||
val event = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, 4.0)
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
|
||||
attribute?.modifiers?.contains(modifier) shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
test("modifiers remain when resistance enabled and supported") {
|
||||
if (!CompatibilityCapabilities.isKnockbackResistanceAvailable()) return@test
|
||||
withConfig {
|
||||
ocm.config.set("old-player-knockback.enable-knockback-resistance", true)
|
||||
module.reload()
|
||||
|
||||
val attributeType = XAttribute.KNOCKBACK_RESISTANCE.get() ?: return@withConfig
|
||||
val attribute = victim.getAttribute(attributeType)
|
||||
val modifier = AttributeModifier(UUID.randomUUID(), "test", 0.5, AttributeModifier.Operation.ADD_NUMBER)
|
||||
attribute?.addModifier(modifier)
|
||||
|
||||
val event = EntityDamageByEntityEvent(attacker, victim, EntityDamageEvent.DamageCause.ENTITY_ATTACK, 4.0)
|
||||
Bukkit.getPluginManager().callEvent(event)
|
||||
|
||||
attribute?.modifiers?.contains(modifier) shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
test("enabled resistance scales horizontal knockback") {
|
||||
if (!CompatibilityCapabilities.isKnockbackResistanceAvailable()) return@test
|
||||
withConfig {
|
||||
ocm.config.set("old-player-knockback.knockback-horizontal", 0.4)
|
||||
ocm.config.set("old-player-knockback.knockback-vertical", 0.4)
|
||||
ocm.config.set("old-player-knockback.knockback-vertical-limit", 0.4)
|
||||
ocm.config.set("old-player-knockback.knockback-extra-horizontal", 0.5)
|
||||
ocm.config.set("old-player-knockback.knockback-extra-vertical", 0.1)
|
||||
ocm.config.set("old-player-knockback.enable-knockback-resistance", true)
|
||||
module.reload()
|
||||
|
||||
val attributeType = XAttribute.KNOCKBACK_RESISTANCE.get() ?: return@withConfig
|
||||
val attribute = victim.getAttribute(attributeType) ?: return@withConfig
|
||||
val originalBase = attribute.baseValue
|
||||
attribute.baseValue = 0.5
|
||||
|
||||
try {
|
||||
val event = damageEvent()
|
||||
event.isCancelled shouldBe false
|
||||
val vector = getPendingVector(victim.uniqueId) ?: error("No knockback stored")
|
||||
val expectedHorizontal = 0.4 * (1 - attribute.value)
|
||||
vector.x shouldBe (expectedHorizontal plusOrMinus 0.0001)
|
||||
vector.y shouldBe (0.4 plusOrMinus 0.0001)
|
||||
removePending(victim.uniqueId)
|
||||
} finally {
|
||||
attribute.baseValue = originalBase
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,480 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.collections.shouldContain
|
||||
import io.kotest.matchers.collections.shouldNotContain
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.api.OldCombatMechanicsAPI
|
||||
import kernitus.plugin.OldCombatMechanics.api.PlayerModesetChangeEvent
|
||||
import kernitus.plugin.OldCombatMechanics.api.PlayerModuleOverrideChangeEvent
|
||||
import kernitus.plugin.OldCombatMechanics.api.PlayerModuleOverride
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModuleDisableOffHand
|
||||
import kernitus.plugin.OldCombatMechanics.module.OCMModule
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.Config
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.EventHandler
|
||||
import org.bukkit.event.HandlerList
|
||||
import org.bukkit.event.Listener
|
||||
import org.bukkit.event.player.PlayerQuitEvent
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class PlayerModuleOverrideApiIntegrationTest : FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val api = Bukkit.getServicesManager().getRegistration(OldCombatMechanicsAPI::class.java)?.provider
|
||||
?: error("OldCombatMechanicsApi service not registered")
|
||||
val module = ModuleLoader.getModules()
|
||||
.filterIsInstance<ModuleDisableOffHand>()
|
||||
.firstOrNull() ?: error("ModuleDisableOffHand not registered")
|
||||
val alwaysEnabledModule = moduleByName("attack-frequency")
|
||||
val modesetModule = moduleByName("old-tool-damage")
|
||||
|
||||
lateinit var player: Player
|
||||
lateinit var fakePlayer: FakePlayer
|
||||
|
||||
fun runSync(action: () -> Unit) {
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
action()
|
||||
} else {
|
||||
Bukkit.getScheduler().callSyncMethod(testPlugin) {
|
||||
action()
|
||||
null
|
||||
}.get()
|
||||
}
|
||||
}
|
||||
|
||||
fun setModeset(player: Player, modeset: String) {
|
||||
val playerData = getPlayerData(player.uniqueId)
|
||||
playerData.setModesetForWorld(player.world.uid, modeset)
|
||||
setPlayerData(player.uniqueId, playerData)
|
||||
}
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
beforeSpec {
|
||||
runSync {
|
||||
val world = checkNotNull(Bukkit.getServer().getWorld("world"))
|
||||
fakePlayer = FakePlayer(testPlugin)
|
||||
fakePlayer.spawn(Location(world, 0.0, 100.0, 0.0))
|
||||
player = checkNotNull(Bukkit.getPlayer(fakePlayer.uuid))
|
||||
}
|
||||
}
|
||||
|
||||
beforeTest {
|
||||
runSync {
|
||||
api.clearAllModuleOverridesForPlayer(player)
|
||||
setModeset(player, "old")
|
||||
}
|
||||
}
|
||||
|
||||
afterTest {
|
||||
runSync {
|
||||
api.clearAllModuleOverridesForPlayer(player)
|
||||
setModeset(player, "old")
|
||||
}
|
||||
}
|
||||
|
||||
afterSpec {
|
||||
runSync {
|
||||
api.clearAllModuleOverridesForPlayer(player)
|
||||
fakePlayer.removePlayer()
|
||||
}
|
||||
}
|
||||
|
||||
test("API can force module enable and disable per player") {
|
||||
runSync {
|
||||
val moduleName = module.configName
|
||||
val disabledModeset = Config.getModesets().entries.firstOrNull { (_, modules) ->
|
||||
!modules.contains(moduleName)
|
||||
}?.key
|
||||
if (disabledModeset != null) {
|
||||
setModeset(player, disabledModeset)
|
||||
}
|
||||
|
||||
val baseline = module.isEnabled(player)
|
||||
|
||||
api.forceEnableModuleForPlayer(player, moduleName)
|
||||
module.isEnabled(player) shouldBe true
|
||||
|
||||
api.forceDisableModuleForPlayer(player, moduleName)
|
||||
module.isEnabled(player) shouldBe false
|
||||
|
||||
api.clearModuleOverrideForPlayer(player, moduleName)
|
||||
module.isEnabled(player) shouldBe baseline
|
||||
}
|
||||
}
|
||||
|
||||
test("unknown and internal module names are rejected") {
|
||||
runSync {
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
api.forceEnableModuleForPlayer(player, "not-a-real-module")
|
||||
}
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
api.forceDisableModuleForPlayer(player, "modeset-listener")
|
||||
}
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
api.getModuleOverrideForPlayer(player, "attack-cooldown-tracker")
|
||||
}
|
||||
|
||||
api.hasAnyOverrideForPlayer(player) shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
test("bulk override setter rejects invalid keys before mutating overrides") {
|
||||
runSync {
|
||||
val moduleName = module.configName
|
||||
val alwaysEnabledModuleName = alwaysEnabledModule.configName
|
||||
val events = mutableListOf<PlayerModuleOverrideChangeEvent>()
|
||||
val listener = object : Listener {
|
||||
@EventHandler
|
||||
fun onOverrideChange(event: PlayerModuleOverrideChangeEvent) {
|
||||
if (event.player.uniqueId == player.uniqueId) {
|
||||
events += event
|
||||
}
|
||||
}
|
||||
}
|
||||
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
|
||||
api.forceEnableModuleForPlayer(player, moduleName)
|
||||
events.clear()
|
||||
|
||||
try {
|
||||
val exception = shouldThrow<IllegalArgumentException> {
|
||||
api.setModuleOverridesForPlayer(
|
||||
player,
|
||||
mapOf(
|
||||
moduleName to PlayerModuleOverride.FORCE_DISABLED,
|
||||
alwaysEnabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
|
||||
"missing-module" to PlayerModuleOverride.FORCE_ENABLED,
|
||||
),
|
||||
)
|
||||
}
|
||||
exception.message shouldBe "Unknown or non-configurable module: missing-module"
|
||||
} finally {
|
||||
HandlerList.unregisterAll(listener)
|
||||
}
|
||||
|
||||
api.getModuleOverridesForPlayer(player) shouldBe mapOf(moduleName to PlayerModuleOverride.FORCE_ENABLED)
|
||||
api.getModuleOverrideForPlayer(player, alwaysEnabledModuleName) shouldBe PlayerModuleOverride.DEFAULT
|
||||
events shouldBe emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
test("bulk override setter rejects duplicate normalised keys before mutating overrides") {
|
||||
runSync {
|
||||
val moduleName = module.configName
|
||||
val alwaysEnabledModuleName = alwaysEnabledModule.configName
|
||||
val events = mutableListOf<PlayerModuleOverrideChangeEvent>()
|
||||
val listener = object : Listener {
|
||||
@EventHandler
|
||||
fun onOverrideChange(event: PlayerModuleOverrideChangeEvent) {
|
||||
if (event.player.uniqueId == player.uniqueId) {
|
||||
events += event
|
||||
}
|
||||
}
|
||||
}
|
||||
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
|
||||
api.forceEnableModuleForPlayer(player, moduleName)
|
||||
events.clear()
|
||||
|
||||
try {
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
api.setModuleOverridesForPlayer(
|
||||
player,
|
||||
mapOf(
|
||||
moduleName to PlayerModuleOverride.FORCE_DISABLED,
|
||||
" ${moduleName.replaceFirstChar { it.uppercase() }} " to PlayerModuleOverride.DEFAULT,
|
||||
alwaysEnabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
|
||||
),
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
HandlerList.unregisterAll(listener)
|
||||
}
|
||||
|
||||
api.getModuleOverridesForPlayer(player) shouldBe mapOf(moduleName to PlayerModuleOverride.FORCE_ENABLED)
|
||||
api.getModuleOverrideForPlayer(player, alwaysEnabledModuleName) shouldBe PlayerModuleOverride.DEFAULT
|
||||
events shouldBe emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
test("module override API emits only changed override events in sorted bulk order") {
|
||||
runSync {
|
||||
val configDisabledModuleName = module.configName
|
||||
val alwaysEnabledModuleName = alwaysEnabledModule.configName
|
||||
val events = mutableListOf<PlayerModuleOverrideChangeEvent>()
|
||||
val listener = object : Listener {
|
||||
@EventHandler
|
||||
fun onOverrideChange(event: PlayerModuleOverrideChangeEvent) {
|
||||
if (event.player.uniqueId == player.uniqueId) {
|
||||
events += event
|
||||
}
|
||||
}
|
||||
}
|
||||
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
|
||||
|
||||
try {
|
||||
api.forceEnableModuleForPlayer(player, configDisabledModuleName)
|
||||
api.forceEnableModuleForPlayer(player, configDisabledModuleName)
|
||||
events.map { it.moduleName } shouldBe listOf(configDisabledModuleName)
|
||||
events.single().previousOverride shouldBe PlayerModuleOverride.DEFAULT
|
||||
events.single().newOverride shouldBe PlayerModuleOverride.FORCE_ENABLED
|
||||
|
||||
events.clear()
|
||||
api.setModuleOverridesForPlayer(
|
||||
player,
|
||||
mapOf(
|
||||
configDisabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
|
||||
alwaysEnabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
|
||||
),
|
||||
)
|
||||
events.map { it.moduleName } shouldBe listOf(alwaysEnabledModuleName, configDisabledModuleName).sorted()
|
||||
events.map { it.newOverride } shouldBe listOf(
|
||||
PlayerModuleOverride.FORCE_DISABLED,
|
||||
PlayerModuleOverride.FORCE_DISABLED,
|
||||
)
|
||||
|
||||
events.clear()
|
||||
api.setModuleOverridesForPlayer(
|
||||
player,
|
||||
mapOf(
|
||||
configDisabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
|
||||
alwaysEnabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
|
||||
),
|
||||
)
|
||||
events shouldBe emptyList()
|
||||
|
||||
events.clear()
|
||||
api.clearAllModuleOverridesForPlayer(player)
|
||||
events.map { it.moduleName } shouldBe listOf(alwaysEnabledModuleName, configDisabledModuleName).sorted()
|
||||
events.map { it.newOverride } shouldBe listOf(
|
||||
PlayerModuleOverride.DEFAULT,
|
||||
PlayerModuleOverride.DEFAULT,
|
||||
)
|
||||
|
||||
events.clear()
|
||||
api.clearAllModuleOverridesForPlayer(player)
|
||||
events shouldBe emptyList()
|
||||
} finally {
|
||||
HandlerList.unregisterAll(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("modeset API emits events only for stored changes") {
|
||||
runSync {
|
||||
setModeset(player, "old")
|
||||
val events = mutableListOf<PlayerModesetChangeEvent>()
|
||||
val listener = object : Listener {
|
||||
@EventHandler
|
||||
fun onModesetChange(event: PlayerModesetChangeEvent) {
|
||||
if (event.player.uniqueId == player.uniqueId) {
|
||||
events += event
|
||||
}
|
||||
}
|
||||
}
|
||||
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
|
||||
|
||||
try {
|
||||
api.setModesetForPlayer(player, "new")
|
||||
events.size shouldBe 1
|
||||
events.single().previousModeset shouldBe "old"
|
||||
events.single().newModeset shouldBe "new"
|
||||
events.single().reason shouldBe PlayerModesetChangeEvent.Reason.API
|
||||
|
||||
api.setModesetForPlayer(player, "new")
|
||||
events.size shouldBe 1
|
||||
} finally {
|
||||
HandlerList.unregisterAll(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("modeset command emits command reason event") {
|
||||
runSync {
|
||||
setModeset(player, "old")
|
||||
val events = mutableListOf<PlayerModesetChangeEvent>()
|
||||
val listener = object : Listener {
|
||||
@EventHandler
|
||||
fun onModesetChange(event: PlayerModesetChangeEvent) {
|
||||
if (event.player.uniqueId == player.uniqueId) {
|
||||
events += event
|
||||
}
|
||||
}
|
||||
}
|
||||
Bukkit.getPluginManager().registerEvents(listener, testPlugin)
|
||||
|
||||
try {
|
||||
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "ocm mode new ${player.name}") shouldBe true
|
||||
events.size shouldBe 1
|
||||
events.single().previousModeset shouldBe "old"
|
||||
events.single().newModeset shouldBe "new"
|
||||
events.single().reason shouldBe PlayerModesetChangeEvent.Reason.COMMAND
|
||||
} finally {
|
||||
HandlerList.unregisterAll(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("Java null bulk override value is rejected before mutating overrides") {
|
||||
runSync {
|
||||
val moduleName = module.configName
|
||||
api.forceEnableModuleForPlayer(player, moduleName)
|
||||
api.getModuleOverrideForPlayer(player, moduleName) shouldBe PlayerModuleOverride.FORCE_ENABLED
|
||||
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
callJavaNullBulkOverrideValue(api, player, moduleName)
|
||||
}
|
||||
|
||||
api.getModuleOverrideForPlayer(player, moduleName) shouldBe PlayerModuleOverride.FORCE_ENABLED
|
||||
api.clearModuleOverrideForPlayer(player, moduleName)
|
||||
}
|
||||
}
|
||||
|
||||
test("force enable overrides config-disabled and modeset-disabled modules") {
|
||||
runSync {
|
||||
val configDisabledModuleName = module.configName
|
||||
module.isEnabled(player) shouldBe false
|
||||
api.isModuleEnabledForPlayer(player, configDisabledModuleName) shouldBe false
|
||||
|
||||
api.forceEnableModuleForPlayer(player, configDisabledModuleName)
|
||||
|
||||
module.isEnabled(player) shouldBe true
|
||||
api.isModuleEnabledForPlayer(player, configDisabledModuleName) shouldBe true
|
||||
|
||||
val modesetModuleName = modesetModule.configName
|
||||
setModeset(player, "new")
|
||||
modesetModule.isEnabled(player) shouldBe false
|
||||
api.isModuleEnabledForPlayer(player, modesetModuleName) shouldBe false
|
||||
|
||||
api.forceEnableModuleForPlayer(player, modesetModuleName)
|
||||
|
||||
modesetModule.isEnabled(player) shouldBe true
|
||||
api.isModuleEnabledForPlayer(player, modesetModuleName) shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
test("force disable overrides config-enabled modules") {
|
||||
runSync {
|
||||
val moduleName = alwaysEnabledModule.configName
|
||||
alwaysEnabledModule.isEnabled(player) shouldBe true
|
||||
api.isModuleEnabledForPlayer(player, moduleName) shouldBe true
|
||||
|
||||
api.forceDisableModuleForPlayer(player, moduleName)
|
||||
|
||||
alwaysEnabledModule.isEnabled(player) shouldBe false
|
||||
api.isModuleEnabledForPlayer(player, moduleName) shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
test("clearing overrides returns to configured and modeset behaviour") {
|
||||
runSync {
|
||||
val configDisabledModuleName = module.configName
|
||||
api.forceEnableModuleForPlayer(player, configDisabledModuleName)
|
||||
api.clearModuleOverrideForPlayer(player, configDisabledModuleName)
|
||||
api.getModuleOverrideForPlayer(player, configDisabledModuleName) shouldBe PlayerModuleOverride.DEFAULT
|
||||
module.isEnabled(player) shouldBe false
|
||||
|
||||
val alwaysEnabledModuleName = alwaysEnabledModule.configName
|
||||
api.forceDisableModuleForPlayer(player, alwaysEnabledModuleName)
|
||||
api.clearModuleOverrideForPlayer(player, alwaysEnabledModuleName)
|
||||
api.getModuleOverrideForPlayer(player, alwaysEnabledModuleName) shouldBe PlayerModuleOverride.DEFAULT
|
||||
alwaysEnabledModule.isEnabled(player) shouldBe true
|
||||
|
||||
val modesetModuleName = modesetModule.configName
|
||||
setModeset(player, "new")
|
||||
api.forceEnableModuleForPlayer(player, modesetModuleName)
|
||||
api.clearModuleOverrideForPlayer(player, modesetModuleName)
|
||||
modesetModule.isEnabled(player) shouldBe false
|
||||
|
||||
setModeset(player, "old")
|
||||
modesetModule.isEnabled(player) shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
test("override query methods expose only active configurable overrides") {
|
||||
runSync {
|
||||
val configDisabledModuleName = module.configName
|
||||
val alwaysEnabledModuleName = alwaysEnabledModule.configName
|
||||
val moduleNames = api.getModuleNames()
|
||||
|
||||
moduleNames shouldContain configDisabledModuleName
|
||||
moduleNames shouldContain alwaysEnabledModuleName
|
||||
moduleNames shouldContain modesetModule.configName
|
||||
moduleNames shouldNotContain "modeset-listener"
|
||||
moduleNames shouldNotContain "attack-cooldown-tracker"
|
||||
moduleNames shouldNotContain "entity-damage-listener"
|
||||
|
||||
api.hasAnyOverrideForPlayer(player) shouldBe false
|
||||
api.setModuleOverridesForPlayer(
|
||||
player,
|
||||
mapOf(
|
||||
configDisabledModuleName to PlayerModuleOverride.FORCE_ENABLED,
|
||||
alwaysEnabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
|
||||
),
|
||||
)
|
||||
|
||||
api.hasAnyOverrideForPlayer(player) shouldBe true
|
||||
api.getModuleOverridesForPlayer(player) shouldBe mapOf(
|
||||
configDisabledModuleName to PlayerModuleOverride.FORCE_ENABLED,
|
||||
alwaysEnabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
|
||||
)
|
||||
|
||||
api.setModuleOverridesForPlayer(
|
||||
player,
|
||||
mapOf(configDisabledModuleName to PlayerModuleOverride.DEFAULT),
|
||||
)
|
||||
|
||||
api.getModuleOverridesForPlayer(player) shouldBe mapOf(
|
||||
alwaysEnabledModuleName to PlayerModuleOverride.FORCE_DISABLED,
|
||||
)
|
||||
api.clearAllModuleOverridesForPlayer(player)
|
||||
api.hasAnyOverrideForPlayer(player) shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
test("player quit lifecycle clears override state") {
|
||||
runSync {
|
||||
val moduleName = module.configName
|
||||
api.forceEnableModuleForPlayer(player, moduleName)
|
||||
api.hasAnyOverrideForPlayer(player) shouldBe true
|
||||
|
||||
Bukkit.getPluginManager().callEvent(PlayerQuitEvent(player, ""))
|
||||
|
||||
api.hasAnyOverrideForPlayer(player) shouldBe false
|
||||
api.getModuleOverrideForPlayer(player, moduleName) shouldBe PlayerModuleOverride.DEFAULT
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private fun moduleByName(moduleName: String): OCMModule =
|
||||
ModuleLoader.getModules()
|
||||
.firstOrNull { it.configName == moduleName }
|
||||
?: error("$moduleName module not registered")
|
||||
|
||||
private fun callJavaNullBulkOverrideValue(api: OldCombatMechanicsAPI, player: Player, moduleName: String) {
|
||||
val helper = Class.forName("kernitus.plugin.OldCombatMechanics.PlayerModuleOverrideJavaInterop")
|
||||
val method = helper.getDeclaredMethod(
|
||||
"setOverridesWithNullValue",
|
||||
OldCombatMechanicsAPI::class.java,
|
||||
Player::class.java,
|
||||
String::class.java,
|
||||
)
|
||||
method.isAccessible = true
|
||||
try {
|
||||
method.invoke(null, api, player, moduleName)
|
||||
} catch (exception: java.lang.reflect.InvocationTargetException) {
|
||||
throw exception.cause ?: exception
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.doubles.plusOrMinus
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.module.ModulePlayerRegen
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.bukkit.Bukkit
|
||||
import org.bukkit.Location
|
||||
import org.bukkit.entity.Player
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent
|
||||
import org.bukkit.plugin.java.JavaPlugin
|
||||
import java.util.concurrent.Callable
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class PlayerRegenIntegrationTest : FunSpec({
|
||||
val testPlugin = JavaPlugin.getPlugin(OCMTestMain::class.java)
|
||||
val ocm = JavaPlugin.getPlugin(OCMMain::class.java)
|
||||
val module = ModuleLoader.getModules().filterIsInstance<ModulePlayerRegen>().firstOrNull()
|
||||
?: error("ModulePlayerRegen not registered")
|
||||
|
||||
lateinit var player: Player
|
||||
lateinit var fakePlayer: FakePlayer
|
||||
|
||||
fun <T> runSync(action: () -> T): T {
|
||||
return if (Bukkit.isPrimaryThread()) action() else Bukkit.getScheduler().callSyncMethod(testPlugin, Callable {
|
||||
action()
|
||||
}).get()
|
||||
}
|
||||
|
||||
fun setModeset(player: Player, modeset: String) {
|
||||
val playerData = kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData(player.uniqueId)
|
||||
playerData.setModesetForWorld(player.world.uid, modeset)
|
||||
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData(player.uniqueId, playerData)
|
||||
ModuleLoader.toggleModules()
|
||||
}
|
||||
|
||||
suspend fun waitServerTicks(ticks: Long) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val task = Bukkit.getScheduler().runTaskLater(testPlugin, Runnable {
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(Unit)
|
||||
}
|
||||
}, ticks)
|
||||
continuation.invokeOnCancellation { task.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun withConfig(intervalMs: Long, amount: Int, exhaustion: Double, block: suspend () -> Unit) {
|
||||
val oldInterval = ocm.config.getLong("old-player-regen.interval")
|
||||
val oldAmount = ocm.config.getInt("old-player-regen.amount")
|
||||
val oldExhaustion = ocm.config.getDouble("old-player-regen.exhaustion")
|
||||
|
||||
try {
|
||||
runSync {
|
||||
ocm.config.set("old-player-regen.interval", intervalMs)
|
||||
ocm.config.set("old-player-regen.amount", amount)
|
||||
ocm.config.set("old-player-regen.exhaustion", exhaustion)
|
||||
module.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
}
|
||||
block()
|
||||
} finally {
|
||||
runSync {
|
||||
ocm.config.set("old-player-regen.interval", oldInterval)
|
||||
ocm.config.set("old-player-regen.amount", oldAmount)
|
||||
ocm.config.set("old-player-regen.exhaustion", oldExhaustion)
|
||||
module.reload()
|
||||
ModuleLoader.toggleModules()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createRegainEvent(player: Player, reason: EntityRegainHealthEvent.RegainReason, amount: Double): EntityRegainHealthEvent {
|
||||
val ctors = EntityRegainHealthEvent::class.java.constructors
|
||||
for (ctor in ctors) {
|
||||
val paramTypes = ctor.parameterTypes
|
||||
val args = arrayOfNulls<Any>(paramTypes.size)
|
||||
var ok = true
|
||||
for (i in paramTypes.indices) {
|
||||
val t = paramTypes[i]
|
||||
args[i] = when {
|
||||
org.bukkit.entity.Entity::class.java.isAssignableFrom(t) -> player
|
||||
t == java.lang.Double.TYPE || t == Double::class.java -> amount
|
||||
EntityRegainHealthEvent.RegainReason::class.java.isAssignableFrom(t) -> reason
|
||||
else -> null
|
||||
}
|
||||
if (args[i] == null && t.isPrimitive) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!ok) continue
|
||||
try {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return ctor.newInstance(*args) as EntityRegainHealthEvent
|
||||
} catch (_: Throwable) {
|
||||
// Try next
|
||||
}
|
||||
}
|
||||
error("No compatible EntityRegainHealthEvent constructor found for this server version")
|
||||
}
|
||||
|
||||
extensions(MainThreadDispatcherExtension(testPlugin))
|
||||
|
||||
beforeSpec {
|
||||
runSync {
|
||||
val world = Bukkit.getWorld("world") ?: error("world not loaded")
|
||||
val location = Location(world, 0.0, 120.0, 0.0, 0f, 0f)
|
||||
fakePlayer = FakePlayer(testPlugin)
|
||||
fakePlayer.spawn(location)
|
||||
player = Bukkit.getPlayer(fakePlayer.uuid) ?: error("Player not found")
|
||||
player.isOp = true
|
||||
setModeset(player, "old")
|
||||
}
|
||||
}
|
||||
|
||||
afterSpec {
|
||||
runSync { fakePlayer.removePlayer() }
|
||||
}
|
||||
|
||||
beforeTest {
|
||||
runSync {
|
||||
setModeset(player, "old")
|
||||
player.health = 20.0
|
||||
player.exhaustion = 0f
|
||||
player.saturation = 0f
|
||||
|
||||
// Ensure per-player state from previous tests does not leak (healTimes is keyed by UUID).
|
||||
runCatching {
|
||||
val names = listOf("lastHealTick", "healTimes")
|
||||
val f = names.asSequence()
|
||||
.mapNotNull { name -> runCatching { ModulePlayerRegen::class.java.getDeclaredField(name) }.getOrNull() }
|
||||
.firstOrNull()
|
||||
if (f != null) {
|
||||
f.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(f.get(module) as? MutableMap<Any, Any>)?.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("SATIATED regen is cancelled and replaced with configured heal + exhaustion") {
|
||||
withConfig(intervalMs = 0, amount = 2, exhaustion = 3.0) {
|
||||
runSync {
|
||||
player.health = 10.0
|
||||
player.exhaustion = 1.0f
|
||||
}
|
||||
|
||||
val event = runSync { createRegainEvent(player, EntityRegainHealthEvent.RegainReason.SATIATED, 1.0) }
|
||||
runSync {
|
||||
module.onRegen(event)
|
||||
event.isCancelled shouldBe true
|
||||
player.health shouldBe (12.0 plusOrMinus 1e-9)
|
||||
|
||||
// Simulate vanilla modifying exhaustion despite the cancellation; OCM applies its own value next tick.
|
||||
player.exhaustion = 2.0f
|
||||
}
|
||||
|
||||
waitServerTicks(2L)
|
||||
|
||||
runSync {
|
||||
player.exhaustion.toDouble() shouldBe (4.0 plusOrMinus 0.0001) // previous(1.0) + config(3.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("heal is skipped when within the configured interval") {
|
||||
withConfig(intervalMs = 60_000, amount = 2, exhaustion = 3.0) {
|
||||
runSync {
|
||||
player.health = 10.0
|
||||
player.exhaustion = 1.0f
|
||||
}
|
||||
|
||||
val first = runSync { createRegainEvent(player, EntityRegainHealthEvent.RegainReason.SATIATED, 1.0) }
|
||||
runSync {
|
||||
module.onRegen(first)
|
||||
player.health shouldBe (12.0 plusOrMinus 1e-9)
|
||||
}
|
||||
|
||||
// Simulate immediate damage, then attempt to heal again within the interval.
|
||||
runSync {
|
||||
player.health = 10.0
|
||||
player.exhaustion = 1.0f
|
||||
}
|
||||
|
||||
val second = runSync { createRegainEvent(player, EntityRegainHealthEvent.RegainReason.SATIATED, 1.0) }
|
||||
runSync {
|
||||
module.onRegen(second)
|
||||
second.isCancelled shouldBe true
|
||||
player.health shouldBe (10.0 plusOrMinus 1e-9)
|
||||
|
||||
// Simulate vanilla exhaustion change; the module should restore to previous exhaustion next tick.
|
||||
player.exhaustion = 3.5f
|
||||
}
|
||||
|
||||
waitServerTicks(2L)
|
||||
|
||||
runSync {
|
||||
player.exhaustion.toDouble() shouldBe (1.0 plusOrMinus 0.0001)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("non-SATIATED regain reasons are not modified") {
|
||||
val nonSatiated = EntityRegainHealthEvent.RegainReason.values().firstOrNull {
|
||||
it != EntityRegainHealthEvent.RegainReason.SATIATED
|
||||
} ?: error("No non-SATIATED regain reason available")
|
||||
|
||||
withConfig(intervalMs = 0, amount = 100, exhaustion = 3.0) {
|
||||
runSync {
|
||||
player.health = 10.0
|
||||
player.exhaustion = 1.0f
|
||||
}
|
||||
val event = runSync { createRegainEvent(player, nonSatiated, 5.0) }
|
||||
runSync {
|
||||
module.onRegen(event)
|
||||
event.isCancelled shouldBe false
|
||||
player.health shouldBe (10.0 plusOrMinus 1e-9)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("healing is clamped to max health") {
|
||||
withConfig(intervalMs = 0, amount = 100, exhaustion = 3.0) {
|
||||
runSync { player.health = 19.5 }
|
||||
val event = runSync { createRegainEvent(player, EntityRegainHealthEvent.RegainReason.SATIATED, 1.0) }
|
||||
runSync {
|
||||
module.onRegen(event)
|
||||
player.health shouldBe (20.0 plusOrMinus 1e-9)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
package kernitus.plugin.OldCombatMechanics
|
||||
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.common.ExperimentalKotest
|
||||
import io.kotest.core.spec.style.StringSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kernitus.plugin.OldCombatMechanics.utilities.reflection.SpigotFunctionChooser
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@OptIn(ExperimentalKotest::class)
|
||||
class SpigotFunctionChooserIntegrationTest :
|
||||
StringSpec({
|
||||
"compatibility failures choose fallback and stay cached" {
|
||||
val compatibilityFailures =
|
||||
listOf<Throwable>(
|
||||
NoSuchMethodError("missing API method"),
|
||||
NoClassDefFoundError("missing API class"),
|
||||
AbstractMethodError("abstract API method"),
|
||||
IncompatibleClassChangeError("binary incompatibility"),
|
||||
CompatibilityUnsupportedOperationException("compatibility fallback approved"),
|
||||
)
|
||||
|
||||
compatibilityFailures.forEach { throwable ->
|
||||
val apiCalls = AtomicInteger(0)
|
||||
val fallbackCalls = AtomicInteger(0)
|
||||
|
||||
val chooser =
|
||||
SpigotFunctionChooser.apiCompatCall<String, Any, String>(
|
||||
{ _, _ ->
|
||||
apiCalls.incrementAndGet()
|
||||
throw throwable
|
||||
},
|
||||
{ _, _ ->
|
||||
fallbackCalls.incrementAndGet()
|
||||
"fallback"
|
||||
},
|
||||
)
|
||||
|
||||
chooser.apply("target", Any()) shouldBe "fallback"
|
||||
chooser.apply("target", Any()) shouldBe "fallback"
|
||||
apiCalls.get() shouldBe 1
|
||||
fallbackCalls.get() shouldBe 2
|
||||
}
|
||||
}
|
||||
|
||||
"ordinary logic failures are surfaced instead of choosing fallback" {
|
||||
val fallbackCalls = AtomicInteger(0)
|
||||
val chooser =
|
||||
SpigotFunctionChooser.apiCompatCall<String, Any, String>(
|
||||
{ _, _ -> throw IllegalStateException("business logic failure") },
|
||||
{ _, _ ->
|
||||
fallbackCalls.incrementAndGet()
|
||||
"fallback"
|
||||
},
|
||||
)
|
||||
|
||||
shouldThrow<IllegalStateException> {
|
||||
chooser.apply("target", Any())
|
||||
}
|
||||
fallbackCalls.get() shouldBe 0
|
||||
}
|
||||
|
||||
"null pointer failures are surfaced instead of choosing fallback" {
|
||||
val fallbackCalls = AtomicInteger(0)
|
||||
val chooser =
|
||||
SpigotFunctionChooser.apiCompatCall<String, Any, String>(
|
||||
{ _, _ -> throw NullPointerException("unexpected null") },
|
||||
{ _, _ ->
|
||||
fallbackCalls.incrementAndGet()
|
||||
"fallback"
|
||||
},
|
||||
)
|
||||
|
||||
shouldThrow<NullPointerException> {
|
||||
chooser.apply("target", Any())
|
||||
}
|
||||
fallbackCalls.get() shouldBe 0
|
||||
}
|
||||
|
||||
"generic unsupported operation with incompatible wording is surfaced" {
|
||||
val fallbackCalls = AtomicInteger(0)
|
||||
val chooser =
|
||||
SpigotFunctionChooser.apiCompatCall<String, Any, String>(
|
||||
{ _, _ -> throw UnsupportedOperationException("incompatible state for this action") },
|
||||
{ _, _ ->
|
||||
fallbackCalls.incrementAndGet()
|
||||
"fallback"
|
||||
},
|
||||
)
|
||||
|
||||
shouldThrow<UnsupportedOperationException> {
|
||||
chooser.apply("target", Any())
|
||||
}
|
||||
fallbackCalls.get() shouldBe 0
|
||||
}
|
||||
})
|
||||
|
||||
private class CompatibilityUnsupportedOperationException(
|
||||
message: String,
|
||||
) : UnsupportedOperationException(message)
|
||||