Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9f7f2de71 | ||
|
|
84e6d3bbc0 | ||
|
|
34a344f39c | ||
|
|
84d351b116 | ||
|
|
ad11a2bcd3 | ||
|
|
dea0370084 | ||
|
|
d6af2f69e0 | ||
|
|
70f6112ad8 | ||
|
|
cbfdfb016b | ||
|
|
47301edaed | ||
|
|
0ab7c7052c | ||
|
|
19e03c5fb4 | ||
|
|
58f9a756e9 | ||
|
|
bdd55cc9bf | ||
|
|
3217e53cda | ||
|
|
881fb85408 | ||
|
|
581475e760 | ||
|
|
32fe81f908 | ||
|
|
31f2a60096 | ||
|
|
cd0a45d284 | ||
|
|
e2447f0b95 | ||
|
|
ca1fe6f252 | ||
|
|
84285e5009 | ||
|
|
a39e26caf7 | ||
|
|
d19ecc5ce0 | ||
|
|
e745010220 | ||
|
|
a580fbbcb7 | ||
|
|
dfdb951c8a | ||
|
|
f3a987dde9 | ||
|
|
ed558517ba | ||
|
|
9f8a881d62 | ||
|
|
d0d3b48000 | ||
|
|
d8d8c18d53 | ||
|
|
c118e8e708 | ||
|
|
07d0a5a192 | ||
|
|
9c2d5db41e | ||
|
|
64cb501321 | ||
|
|
e5e460ce20 | ||
|
|
19aecebc90 | ||
|
|
53cadf2282 | ||
|
|
ec4bd415d2 | ||
|
|
6a2bfaf1ff | ||
|
|
5d89ff54c9 | ||
|
|
ebb17d69bf | ||
|
|
478aa97ca8 | ||
|
|
88db479ede | ||
|
|
2822ba9c9a | ||
|
|
0c18137401 | ||
|
|
b64e7e336c | ||
|
|
6f9202859d | ||
|
|
f2808a5923 | ||
|
|
494ab22227 | ||
|
|
ba613ff953 | ||
|
|
932623ed88 | ||
|
|
dc5633c740 | ||
|
|
24897c2b97 | ||
|
|
ab0f15f248 | ||
|
|
77635b8db2 | ||
|
|
8486adb53c | ||
|
|
c07c97b7ea | ||
|
|
c17c606ff2 | ||
|
|
f8756a24df | ||
|
|
be8d0cb615 | ||
|
|
2e0fdea5be | ||
|
|
21e23bbc89 | ||
|
|
1e15bfb523 | ||
|
|
a1007a19f9 | ||
|
|
dd4bb7baba | ||
|
|
31c7077a64 | ||
|
|
9656ff4e8f | ||
|
|
14cc943068 | ||
|
|
96aef279cf | ||
|
|
efe00d92af | ||
|
|
643f2a81cf | ||
|
|
e13869923f | ||
|
|
20052d660b | ||
|
|
9cf9fd745c | ||
|
|
4d34606d8e | ||
|
|
55ec15bb5b | ||
|
|
fbc0e94fc2 | ||
|
|
81e8323d56 | ||
|
|
393ef114e7 | ||
|
|
7a5fb5f171 | ||
|
|
9884f79cf6 | ||
|
|
b7f2d0da57 | ||
|
|
82524dc9e9 | ||
|
|
66e28db552 | ||
|
|
919382c377 | ||
|
|
2c138b6608 | ||
|
|
7a2eb8e5d8 | ||
|
|
451d088697 | ||
|
|
d56fcbd3cd | ||
|
|
32aa2bab05 | ||
|
|
e941365a41 | ||
|
|
606728d09a | ||
|
|
fe6f6d8f80 | ||
|
|
0b77d1c211 | ||
|
|
383639a1b5 | ||
|
|
374cbdbc11 | ||
|
|
841fd928a5 | ||
|
|
ccb67a0bf5 | ||
|
|
68bbb52ae6 | ||
|
|
176e64187c | ||
|
|
7aad66f219 | ||
|
|
62ebf5c771 | ||
|
|
cef088396f | ||
|
|
95a8dba554 | ||
|
|
b62b5ebd83 | ||
|
|
52dd5c54e9 | ||
|
|
f3306589e0 | ||
|
|
61d53d7689 | ||
|
|
fb297b34fb | ||
|
|
5e778a1a6d | ||
|
|
50498ffb41 | ||
|
|
441569e758 | ||
|
|
42c51e06a8 | ||
|
|
e2742d8a6e | ||
|
|
19eebf1358 | ||
|
|
78d3e11c21 | ||
|
|
002b0e47a9 | ||
|
|
adae149791 | ||
|
|
668a5f3d38 | ||
|
|
27ac366046 | ||
|
|
dea31b8222 | ||
|
|
233060e7bd | ||
|
|
8388d68cb7 | ||
|
|
2bcc449bc4 | ||
|
|
d8ff4b0c92 | ||
|
|
c65b64030f | ||
|
|
448f23ac53 | ||
|
|
fb8b06c4e5 | ||
|
|
4ab2b2dfd8 | ||
|
|
baba10b84d | ||
|
|
5e98830829 | ||
|
|
8e84c58bcc | ||
|
|
b6ad95171d | ||
|
|
c1bad334b7 | ||
|
|
1e5576432d | ||
|
|
fbdcca4089 | ||
|
|
fd2728fe42 | ||
|
|
385301432a | ||
|
|
989b86f6e6 | ||
|
|
e848746800 | ||
|
|
915d7d1143 | ||
|
|
e00a2f6e4b | ||
|
|
720757463b | ||
|
|
6260ee11f9 | ||
|
|
e4ff388281 | ||
|
|
12c6d14916 | ||
|
|
39b74f6c4e | ||
|
|
eac0da081e | ||
|
|
b11beb7126 | ||
|
|
eeecb551bc | ||
|
|
7e9be87e7d | ||
|
|
5dcf887236 | ||
|
|
7e1f082b74 | ||
|
|
9a67a4a8bb | ||
|
|
1e7b1a21b2 | ||
|
|
15e7ab62f9 | ||
|
|
c249355e27 | ||
|
|
c50b06361e | ||
|
|
a521b4c59a | ||
|
|
1dba2690a1 | ||
|
|
2b7daa1928 | ||
|
|
f008c49e84 | ||
|
|
16214872aa | ||
|
|
d8a4c820fb | ||
|
|
31b4594c2e | ||
|
|
4f6f0d6252 | ||
|
|
499cf10aba | ||
|
|
df86721562 | ||
|
|
28a33e7c02 | ||
|
|
5ad1843200 | ||
|
|
6f844b5714 | ||
|
|
2429d2b614 | ||
|
|
e6e7664244 | ||
|
|
005b8db0d4 | ||
|
|
477dbab970 | ||
|
|
b91814d837 | ||
|
|
43df7808cd | ||
|
|
3b8fbdf1b7 | ||
|
|
920cf79641 | ||
|
|
1a83fe7c55 | ||
|
|
bcaac7ee71 | ||
|
|
d489c01000 | ||
|
|
411210c341 | ||
|
|
b78f87db62 | ||
|
|
079ff0587f | ||
|
|
e7c862f4e8 | ||
|
|
51e56ec85e | ||
|
|
8069070219 | ||
|
|
bf421cbd18 | ||
|
|
a517d12feb | ||
|
|
5cca422ba9 | ||
|
|
307db659bc | ||
|
|
fe89f9649e | ||
|
|
6609b29fab | ||
|
|
ef03f1b90b | ||
|
|
1d56705365 | ||
|
|
8d3442c966 | ||
|
|
89b876754c | ||
|
|
47678a4c3d | ||
|
|
f92e251bdc | ||
|
|
383e1bc7df | ||
|
|
ca8070e31b | ||
|
|
168d4f39dc | ||
|
|
862a2736b3 | ||
|
|
aaffc760f3 | ||
|
|
8ce4d58195 | ||
|
|
6149794e36 | ||
|
|
0d3547dded | ||
|
|
a1e1e919e1 | ||
|
|
a145a90f41 | ||
|
|
b8ccd56818 | ||
|
|
89525a4e6b | ||
|
|
8b8823e01d | ||
|
|
38d06c3cfa | ||
|
|
d20a525855 | ||
|
|
faa17ded32 | ||
|
|
e8588ae408 | ||
|
|
3f7c8e4001 | ||
|
|
4692b32a6b | ||
|
|
5d3b2c81c9 | ||
|
|
0ec3bc46dd | ||
|
|
53c805c68f | ||
|
|
34aca10edb | ||
|
|
cdce0c0e19 | ||
|
|
b488dfa04f | ||
|
|
9213918995 | ||
|
|
4ae8b595e7 | ||
|
|
23ab0c3457 | ||
|
|
fc72e231d3 | ||
|
|
c64bc718a6 | ||
|
|
e860b6ff4f | ||
|
|
a6d70aed3c | ||
|
|
2fc34375d3 | ||
|
|
9958f09fcd | ||
|
|
8523e52919 | ||
|
|
b1379350b5 | ||
|
|
e0644a0ec4 | ||
|
|
e4640d9102 | ||
|
|
09d0be84b3 | ||
|
|
bc46ec27d0 | ||
|
|
7ab5aee87c | ||
|
|
f19efa89a8 | ||
|
|
182545de59 | ||
|
|
c4a5fc9a61 | ||
|
|
caaf09fce0 | ||
|
|
1133f37bf9 | ||
|
|
0ddc47b5d4 | ||
|
|
c0be75474f | ||
|
|
daa4a3cb7c | ||
|
|
09873425b9 | ||
|
|
2b20098f09 | ||
|
|
e2dd59e89e | ||
|
|
d66a792a2e | ||
|
|
87d190a013 | ||
|
|
481e2ea90b | ||
|
|
4b19b65a84 | ||
|
|
7f425f2c9f | ||
|
|
4a03608540 | ||
|
|
60504ebdf1 | ||
|
|
79e6e8354b | ||
|
|
b890480a55 | ||
|
|
104286a66e | ||
|
|
996fd41d8a | ||
|
|
c6e4e0a520 | ||
|
|
3f7f7f6c33 | ||
|
|
1f4f9e343a | ||
|
|
b96cf8c664 | ||
|
|
9d52051023 | ||
|
|
d96f16e337 | ||
|
|
535d32f2bb | ||
|
|
c79924f67d | ||
|
|
f5a3dfbda5 | ||
|
|
c568703996 | ||
|
|
3251a48b99 | ||
|
|
f83458fd4b | ||
|
|
c3d2f81e9e | ||
|
|
f8b44dd97b | ||
|
|
ab4fe441fe | ||
|
|
317d1fdbcc | ||
|
|
7ba43d820a | ||
|
|
a5edbfe314 | ||
|
|
e791548e87 | ||
|
|
501aa40677 | ||
|
|
181c2acb34 | ||
|
|
bf0688b291 | ||
|
|
765fbd74c1 | ||
|
|
d2180cec1c | ||
|
|
9ccda3f541 | ||
|
|
401fa2453a | ||
|
|
6946cfef6d | ||
|
|
89fa765351 | ||
|
|
a118aac18c | ||
|
|
da4299cc2f | ||
|
|
112f992fda | ||
|
|
3674e1e950 | ||
|
|
abdd3ad7c7 | ||
|
|
a3c8cec664 | ||
|
|
5a0166e90c | ||
|
|
6c6cc94905 | ||
|
|
f5a5b28f61 | ||
|
|
e3b459a00c | ||
|
|
3ef7fbdb7a | ||
|
|
3795dd90eb | ||
|
|
513e4c56ad | ||
|
|
8138b97b59 | ||
|
|
697b1069a2 | ||
|
|
c7cdb60b47 | ||
|
|
7489c60286 | ||
|
|
8ce2e727bb | ||
|
|
e7402985d5 | ||
|
|
c0ab91edba | ||
|
|
529b83666c | ||
|
|
569611c40f | ||
|
|
61e4ec967f | ||
|
|
57788016a4 | ||
|
|
ad935cbe09 | ||
|
|
ae94f8edef | ||
|
|
9af6c0cca0 | ||
|
|
49ad056a71 | ||
|
|
c0bb962a11 | ||
|
|
0db237ed04 | ||
|
|
5d994c0dad | ||
|
|
0ff79aa959 | ||
|
|
fa4402d426 | ||
|
|
2fb801a59b | ||
|
|
69868a4c5c | ||
|
|
4888bb414e | ||
|
|
890c637373 | ||
|
|
912bd62201 | ||
|
|
0bd4d454c3 | ||
|
|
bf32e528f1 | ||
|
|
5ef86ddfe8 | ||
|
|
e08a6bc4c7 | ||
|
|
5ec61a1d2e | ||
|
|
8c1e1a89bd | ||
|
|
bc155526ab | ||
|
|
4ae1589883 | ||
|
|
9121bb069d | ||
|
|
d306431c55 | ||
|
|
0354156eec | ||
|
|
aa8b7ce47a | ||
|
|
483acbdbb6 | ||
|
|
0775f2e84d | ||
|
|
ebd26ee123 | ||
|
|
0ff274896a | ||
|
|
f345f60ac0 | ||
|
|
babbe8936a | ||
|
|
0e0d6c6247 | ||
|
|
c6d0222c1e | ||
|
|
2bdb577c89 | ||
|
|
b29284bb1b | ||
|
|
97d96d747d | ||
|
|
2c9aa6bd8e | ||
|
|
89b3167d67 | ||
|
|
2e869a1c94 | ||
|
|
9d8d70f63f | ||
|
|
e03db026b9 | ||
|
|
36b8192dc9 | ||
|
|
2c12bbde8b | ||
|
|
9b725791aa | ||
|
|
0b8feab3e5 | ||
|
|
4c100a0c9f | ||
|
|
5fa12cddbf | ||
|
|
0ae5c8d16e | ||
|
|
d7afdd2a47 | ||
|
|
ac92cdb31e | ||
|
|
4812c093ea | ||
|
|
9916968152 | ||
|
|
41d8ffce3d | ||
|
|
2e417c67b3 | ||
|
|
626fbcd591 | ||
|
|
e4e74f1cb8 | ||
|
|
8fd5b06960 | ||
|
|
70bbdba16d | ||
|
|
dfd8fee381 | ||
|
|
7a14457713 | ||
|
|
a8451a4ccf | ||
|
|
2df8c44cda | ||
|
|
46a3dc2515 | ||
|
|
9fab58afe8 | ||
|
|
33ea4292b7 | ||
|
|
f4c5c0049a | ||
|
|
ee22930efd | ||
|
|
612ec7a66b | ||
|
|
b6fb3c3ab2 | ||
|
|
5ed006538e | ||
|
|
dec41c7b67 | ||
|
|
8b03ff4dcb | ||
|
|
2f29f3beb1 | ||
|
|
2d84153571 | ||
|
|
ebd35f5c1c | ||
|
|
0eca13f5a1 | ||
|
|
e658aa3274 | ||
|
|
121c12c71d | ||
|
|
7631e30e2a | ||
|
|
3c15e6b060 | ||
|
|
3c3d7cdbf2 | ||
|
|
caa58977a4 | ||
|
|
e18587c432 | ||
|
|
6bf3fceed6 | ||
|
|
ebece189e1 | ||
|
|
a5a63dc5d8 | ||
|
|
4297e0e02d | ||
|
|
3ebd8f0d5f | ||
|
|
7c8c7f7818 | ||
|
|
49461ac042 | ||
|
|
dd7166d73e | ||
|
|
6d655787ce | ||
|
|
d18884a3ee | ||
|
|
751eeecfd5 | ||
|
|
79b5af66b2 | ||
|
|
164267208a | ||
|
|
4cdd32913e | ||
|
|
122850f6f4 | ||
|
|
035dd418fc | ||
|
|
24e5248cec | ||
|
|
28198114e6 | ||
|
|
f0b0f23827 | ||
|
|
d62b61ef51 | ||
|
|
273a462cd3 | ||
|
|
893b431757 | ||
|
|
e847a72079 | ||
|
|
18000efb62 | ||
|
|
b403e4298d | ||
|
|
6d40243fd2 | ||
|
|
8a54123d3a | ||
|
|
a306a00908 | ||
|
|
b901a72140 | ||
|
|
cc8727a2f9 | ||
|
|
57e9ba46a0 | ||
|
|
0c3478d1ff | ||
|
|
ba0b7fb879 | ||
|
|
3118f9bd1a | ||
|
|
f18e7485c7 | ||
|
|
8b147b2531 | ||
|
|
b1ea407f3f | ||
|
|
c9e740d4c9 | ||
|
|
a716424dd5 | ||
|
|
cb37eceb9b | ||
|
|
2618cc5d80 | ||
|
|
bca19bf3d0 | ||
|
|
7f01f75343 | ||
|
|
5edbcb57c3 | ||
|
|
d5cebbf6ad | ||
|
|
afa53876ad | ||
|
|
c2cf7ff220 | ||
|
|
91b03fd127 | ||
|
|
8da0ac68c8 | ||
|
|
99d83f1b07 | ||
|
|
5e10cbf420 | ||
|
|
729e1383c1 | ||
|
|
2cc8490016 | ||
|
|
41b50f7ee8 | ||
|
|
fc495802f2 | ||
|
|
abaf54470c | ||
|
|
53a7430184 | ||
|
|
56e53a1b07 | ||
|
|
7eb350a8da | ||
|
|
3d9475ccec | ||
|
|
4a28287dec | ||
|
|
7e1dbf26c8 | ||
|
|
e9724fd47e | ||
|
|
dcd1170851 | ||
|
|
40cafa4b74 | ||
|
|
3ece4c8369 | ||
|
|
106211564b | ||
|
|
c9a3d24e64 | ||
|
|
6f004f257f | ||
|
|
46897f06af | ||
|
|
fb871e886a | ||
|
|
925017e21a | ||
|
|
2f35b945fc | ||
|
|
addc5563c6 | ||
|
|
1e6d2419db | ||
|
|
17b7513276 | ||
|
|
698a6445ed | ||
|
|
36568cb31a | ||
|
|
5574749351 | ||
|
|
6a6685bcec | ||
|
|
18da80c799 | ||
|
|
6a7d3bd635 | ||
|
|
0bab938784 | ||
|
|
c437851470 | ||
|
|
265503be38 | ||
|
|
c8e8fe88e4 | ||
|
|
4f99b6af05 | ||
|
|
6b78165019 | ||
|
|
24aa623f7a | ||
|
|
7c42f60709 | ||
|
|
45dbc01eae | ||
|
|
3d3c830a6e | ||
|
|
77c49a1fa5 | ||
|
|
2cd56d637b | ||
|
|
90365bb3eb | ||
|
|
2706c0aa2a | ||
|
|
141a29a39c | ||
|
|
60ce93617b | ||
|
|
c303849335 | ||
|
|
c41ef2eacc | ||
|
|
ac8b23faff | ||
|
|
bbbbf54b3f | ||
|
|
fe4bee53de | ||
|
|
fa9be74e1f | ||
|
|
c94fb60a86 | ||
|
|
0223ece81d | ||
|
|
0949ae18b0 | ||
|
|
a33757a465 | ||
|
|
f3362380a3 | ||
|
|
47b21f31fc | ||
|
|
3555f26583 | ||
|
|
4fa9808ddc | ||
|
|
82242ed428 | ||
|
|
8aa20c4d39 | ||
|
|
ccf5a4e12d | ||
|
|
e02a05a1b6 | ||
|
|
5530d7e862 | ||
|
|
f7d13eb44c | ||
|
|
5eb0000029 | ||
|
|
9befb46afa | ||
|
|
b21835d00a | ||
|
|
e9eea049d9 | ||
|
|
2dfdad3a81 | ||
|
|
e23b2df365 | ||
|
|
4105501ec6 | ||
|
|
b26f979924 | ||
|
|
06f8bd68cd | ||
|
|
6d0c3a820a | ||
|
|
ae018f57e5 | ||
|
|
8ef4744cbd | ||
|
|
282930bfe6 | ||
|
|
09a4b1d869 | ||
|
|
a7ff29cdc3 | ||
|
|
6c738d484f | ||
|
|
a8005e6666 | ||
|
|
8c06b48989 | ||
|
|
d0c4f45928 | ||
|
|
7efa78dbf9 | ||
|
|
179f3a8355 | ||
|
|
f72875f0f7 | ||
|
|
f6d5540b16 | ||
|
|
3c3e902a6a | ||
|
|
b83202c08e | ||
|
|
ce0052cdfb | ||
|
|
ae2ae77a02 | ||
|
|
e8c83bba98 | ||
|
|
642e587cdd | ||
|
|
ffd11a0b9c | ||
|
|
33afe26b46 | ||
|
|
30e7f68c85 | ||
|
|
a06f7a669b | ||
|
|
f333e59621 | ||
|
|
80c8715264 | ||
|
|
f67efd0f8d | ||
|
|
3a3f4f1cd2 | ||
|
|
11e1d27013 | ||
|
|
3402a56d1c | ||
|
|
f2866ada9e | ||
|
|
48e1692822 | ||
|
|
44a9edca1b | ||
|
|
ca54dd61ba | ||
|
|
47e1ad3be5 | ||
|
|
46b483848c | ||
|
|
40edd85ea7 | ||
|
|
59313d4f32 | ||
|
|
c86e9f76b9 | ||
|
|
142b15973a | ||
|
|
3e21bbb112 | ||
|
|
df6cc2bea8 | ||
|
|
d23ce56606 | ||
|
|
eb3c336e38 | ||
|
|
f1cd0da7d4 | ||
|
|
329bca61a9 | ||
|
|
b6863c45c7 | ||
|
|
2867c7614a | ||
|
|
23337695b4 | ||
|
|
15f473abc8 | ||
|
|
d47997294d | ||
|
|
6d569fed23 | ||
|
|
6e24546a6c | ||
|
|
eb0b39d8b9 | ||
|
|
10e148be5f | ||
|
|
9599a66cc5 | ||
|
|
c05c6d9512 | ||
|
|
2a976710e9 | ||
|
|
c2a8385895 | ||
|
|
3c71664284 | ||
|
|
c9e2f71361 | ||
|
|
3c085e086d | ||
|
|
28a120fea0 | ||
|
|
dee6ab8eed | ||
|
|
5273220716 | ||
|
|
e416372a41 | ||
|
|
bbf23aad31 | ||
|
|
7ae4f46f75 | ||
|
|
4a74a0a77f | ||
|
|
9944fd5f16 | ||
|
|
3662466434 | ||
|
|
302268d0da | ||
|
|
6ec438abd9 | ||
|
|
9e3182e1a1 | ||
|
|
8cd380a93b | ||
|
|
a3030c3b80 | ||
|
|
2a5db378d3 | ||
|
|
05ebd07df6 | ||
|
|
542879177c | ||
|
|
69c9e0ceb9 | ||
|
|
58cc9677ae | ||
|
|
c691e9c44f | ||
|
|
70b8e991da | ||
|
|
ab2c4da6de | ||
|
|
9f185df6d9 | ||
|
|
ce13396c22 | ||
|
|
439da63793 | ||
|
|
92552f6007 | ||
|
|
152ebc2dc6 | ||
|
|
1550e3a461 | ||
|
|
575e54a27a | ||
|
|
d9e3b74fd9 | ||
|
|
fa9c5cf601 | ||
|
|
cd2d94c9d0 | ||
|
|
de3e1c8e83 | ||
|
|
34505ea2f5 | ||
|
|
b5d6519d11 | ||
|
|
73bee83051 | ||
|
|
7d8dc8f4c9 | ||
|
|
07791fa613 | ||
|
|
fed72b773f | ||
|
|
ebcb11338c | ||
|
|
8d4731a873 | ||
|
|
203c9d423a | ||
|
|
2c7f765fb0 | ||
|
|
8072855d94 | ||
|
|
873aca91bf | ||
|
|
bcca065931 | ||
|
|
a34cc92327 | ||
|
|
44302d8eff | ||
|
|
7624b7cb7d | ||
|
|
a4b09254f4 | ||
|
|
68a6d2e7d4 | ||
|
|
7d70f8f92e | ||
|
|
c66ec5eaaa | ||
|
|
aa719dc2da | ||
|
|
32371a6e30 | ||
|
|
7953e9de13 | ||
|
|
038e2f3e87 | ||
|
|
a35ddb8418 | ||
|
|
3c65360f9c | ||
|
|
7b0722b039 | ||
|
|
49fb264378 | ||
|
|
7dca1a71e1 |
@@ -0,0 +1,21 @@
|
||||
name: Check Source
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check workspace
|
||||
run: |
|
||||
echo "=== PWD ==="
|
||||
pwd
|
||||
echo "=== LS ==="
|
||||
ls -la
|
||||
echo "=== FIND POM ==="
|
||||
find / -name "pom.xml" 2>/dev/null
|
||||
echo "=== ENV ==="
|
||||
env | grep -iE "workspace|repo|git" | sort
|
||||
echo "=== DONE ==="
|
||||
@@ -0,0 +1,123 @@
|
||||
name: Auto Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Setup JDK 25
|
||||
run: |
|
||||
echo "Setting up JDK 25..."
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq wget unzip
|
||||
wget -q https://api.adoptium.net/v3/binary/latest/25/ga/linux/x64/jdk/hotspot/normal/eclipse?project=jdk -O /tmp/jdk25.tar.gz
|
||||
tar -xzf /tmp/jdk25.tar.gz -C /opt
|
||||
export JAVA_HOME=$(ls -d /opt/jdk-25* | head -1)
|
||||
echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV
|
||||
echo "$JAVA_HOME/bin" >> $GITHUB_PATH
|
||||
echo "JDK 25 installed: $($JAVA_HOME/bin/java -version 2>&1 | head -1)"
|
||||
echo "Setup completed"
|
||||
|
||||
- name: Setup Maven
|
||||
run: |
|
||||
echo "Setting up Maven..."
|
||||
apt-get install -y -qq maven
|
||||
echo "Maven version: $(mvn -version 2>&1 | head -1)"
|
||||
echo "Setup completed"
|
||||
|
||||
- name: Checkout code
|
||||
run: |
|
||||
echo "Checking out code..."
|
||||
GIT_HOST=${GITHUB_SERVER_URL#http://}
|
||||
GIT_HOST=${GIT_HOST#https://}
|
||||
rm -rf /workspace/youpaishidifu/Sdf1_login/*
|
||||
rm -rf /workspace/youpaishidifu/Sdf1_login/.[!.]*
|
||||
git clone "http://${GIT_HOST}/$(echo ${GITHUB_REPOSITORY} | tr '[:upper:]' '[:lower:]').git" .
|
||||
echo "Checkout completed"
|
||||
|
||||
- name: Build with Maven
|
||||
run: |
|
||||
echo "Building with Maven..."
|
||||
mvn -B clean package -DskipTests
|
||||
echo "Build completed"
|
||||
|
||||
- name: Verify JAR
|
||||
run: |
|
||||
echo "Verifying JAR..."
|
||||
ls -la target/*.jar
|
||||
echo "Verification completed"
|
||||
|
||||
- name: Generate Release Notes
|
||||
run: |
|
||||
echo "Generating release notes..."
|
||||
VERSION=$(grep "^version:" src/main/resources/plugin.yml | awk '{print $2}' | tr -d '\r\n ')
|
||||
TAG="v$VERSION"
|
||||
|
||||
# Generate release notes from last 10 commits
|
||||
echo "## Changes in $TAG" > /tmp/release_body.md
|
||||
echo "" >> /tmp/release_body.md
|
||||
git log --format="- **%h** %s (%an, %ad)" --date=short -10 >> /tmp/release_body.md
|
||||
echo "" >> /tmp/release_body.md
|
||||
echo "### Full changelog" >> /tmp/release_body.md
|
||||
echo "" >> /tmp/release_body.md
|
||||
echo "[View all commits](/youpaishidifu/Sdf1_login/commits/main)" >> /tmp/release_body.md
|
||||
|
||||
echo "Release notes generated for $TAG"
|
||||
cat /tmp/release_body.md
|
||||
|
||||
- name: Create Release
|
||||
env:
|
||||
GITEA_TOKEN: 194187cbdc8e1f1d3bc810b1b9e07ddc904a1f73
|
||||
run: |
|
||||
set -e
|
||||
VERSION=$(grep "^version:" src/main/resources/plugin.yml | awk '{print $2}' | tr -d '\r\n ')
|
||||
TAG="v$VERSION"
|
||||
BASE="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY"
|
||||
echo "Version: $VERSION, Tag: $TAG"
|
||||
|
||||
echo "=== Checking existing tag $TAG ==="
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $GITEA_TOKEN" "$BASE/releases/tags/$TAG")
|
||||
echo "Check tag HTTP: $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
OLD_ID=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$BASE/releases/tags/$TAG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
|
||||
echo "Tag $TAG exists (release #$OLD_ID), deleting..."
|
||||
curl -s -X DELETE -H "Authorization: token $GITEA_TOKEN" "$BASE/releases/$OLD_ID"
|
||||
curl -s -X DELETE -H "Authorization: token $GITEA_TOKEN" "$BASE/tags/$TAG"
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
echo "=== Creating release ==="
|
||||
# Use python3 to construct proper JSON
|
||||
python3 -c "
|
||||
import json, sys
|
||||
body = open('/tmp/release_body.md').read()
|
||||
data = {'tag_name': '$TAG', 'name': '$TAG', 'body': body, 'draft': False, 'prerelease': False}
|
||||
print(json.dumps(data))
|
||||
" > /tmp/release_payload.json
|
||||
|
||||
RESP=$(curl -s -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$BASE/releases" \
|
||||
-d @/tmp/release_payload.json)
|
||||
|
||||
RELEASE_ID=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
|
||||
echo "Created release #$RELEASE_ID"
|
||||
|
||||
echo "=== Uploading artifact ==="
|
||||
JAR=$(ls target/*.jar | head -1)
|
||||
UPLOAD_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/jar" \
|
||||
"$BASE/releases/$RELEASE_ID/assets?name=$(basename $JAR)" \
|
||||
--data-binary "@$JAR")
|
||||
echo "Upload HTTP: $UPLOAD_CODE"
|
||||
|
||||
echo "=== Release $TAG created successfully ==="
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
name: Simple Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Hello
|
||||
run: echo "Hello from Gitea Actions!"
|
||||
@@ -0,0 +1,15 @@
|
||||
name: Test Env
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Show env
|
||||
run: |
|
||||
echo "=== ENV ==="
|
||||
env | sort
|
||||
echo "=== DONE ==="
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Auto Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from plugin.yml
|
||||
id: version
|
||||
run: |
|
||||
echo "version=$(grep '^version: ' src/main/resources/plugin.yml | sed 's/version: //')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up JDK 26
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '26'
|
||||
distribution: 'zulu'
|
||||
|
||||
- name: Build with Maven
|
||||
run: mvn -B clean package -DskipTests
|
||||
|
||||
- name: Verify JAR exists
|
||||
run: ls -la target/Sdf1_login.jar
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ steps.version.outputs.version }}
|
||||
name: v${{ steps.version.outputs.version }}
|
||||
files: target/Sdf1_login.jar
|
||||
generate_release_notes: true
|
||||
@@ -39,8 +39,85 @@ build/
|
||||
.DS_Store
|
||||
|
||||
# 开发辅助文件(本地保留,不推送到仓库)
|
||||
/代码关联关系.md
|
||||
txt/
|
||||
*.xlsx
|
||||
*.patch
|
||||
backup/
|
||||
新建*.txt
|
||||
群戏归类.txt
|
||||
群戏归类.txt
|
||||
upload_sftp.py
|
||||
deploy_*.py
|
||||
deploy_*.ps1
|
||||
deploy_*.sh
|
||||
|
||||
# PHP Web 后端
|
||||
web/Web.db
|
||||
web/debug_log_tail.txt
|
||||
web/upload_debug.ps1
|
||||
web/api/*.bak*
|
||||
# 敏感文件 - 禁止提交上传/调试脚本
|
||||
web/upload_*.php
|
||||
web/self_update.php
|
||||
web/version_check.php
|
||||
web/debug_*.php
|
||||
web/db/debug_*.php
|
||||
# 支付密钥 - 严禁提交 (double insurance,配合 Web/.gitignore)
|
||||
Web/密钥文件_git拉黑.txt
|
||||
web/密钥文件_git拉黑.txt
|
||||
Web/pay_secrets.php
|
||||
web/pay_secrets.php
|
||||
|
||||
# Windows 保留设备名(无法用普通 rm 删除)
|
||||
nul
|
||||
con
|
||||
prn
|
||||
aux
|
||||
com1
|
||||
com2
|
||||
com3
|
||||
com4
|
||||
com5
|
||||
com6
|
||||
com7
|
||||
com8
|
||||
com9
|
||||
lpt1
|
||||
lpt2
|
||||
lpt3
|
||||
lpt4
|
||||
lpt5
|
||||
lpt6
|
||||
lpt7
|
||||
lpt8
|
||||
lpt9hs_err_pid*.log
|
||||
hs_err_pid*.log
|
||||
replay_pid*.log
|
||||
|
||||
# 屏蔽patch文件
|
||||
*.patch
|
||||
# 屏蔽wb产生的工作文件
|
||||
.workbuddy/
|
||||
# 屏蔽备份文件
|
||||
backup/
|
||||
# 屏蔽根目录下的所有json
|
||||
/*.json
|
||||
# 屏蔽垃圾文件
|
||||
*.idx
|
||||
*.pack
|
||||
*.rar
|
||||
test.*
|
||||
/*.rev
|
||||
# 屏蔽全部日志文件
|
||||
*.log
|
||||
# 屏蔽开发计划相关文件
|
||||
*.xlsx
|
||||
*.docs
|
||||
*.doc
|
||||
*.txt
|
||||
# 屏蔽全部部署脚本(wb用)
|
||||
*.py
|
||||
# 屏蔽其它文件
|
||||
*.md
|
||||
# 过滤readme
|
||||
!readme.md
|
||||
|
||||
@@ -8,7 +8,5 @@
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="26" project-jdk-type="JavaSDK" />
|
||||
</project>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -1,11 +1,20 @@
|
||||
# Sdf1系列登录插件使用说明
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目(Sdf1_login 插件,含 Java 端与 php 端)采用 **GNU Affero General Public License v3.0(AGPL-3.0)** 授权,协议全文见 [LICENSE](LICENSE)。
|
||||
|
||||
- 你可以自由使用、修改、分发本项目。
|
||||
- **若你把本项目(或其修改版)部署为网络服务对外开放,必须向使用该服务的用户提供完整的对应源代码**(AGPL 第 13 条)。
|
||||
- 衍生作品必须同样以 AGPL-3.0 授权分发。
|
||||
- 本项目按「原样」提供,不附带任何担保。
|
||||
|
||||
# 特别说明:本插件仅有简中语言版本,如需除简中以外的任何版本,请下载插件源码,自行修改!
|
||||
|
||||
## 1. 功能介绍
|
||||
|
||||
| 名字 | 描述/作用 | 施工状态 |
|
||||
| :--- | :--- | :--- |
|
||||
| :---: | :---: | :---: |
|
||||
| 登录 | 用户登录/注册 | 已完成 |
|
||||
| 世界垃圾箱 | 清理服务器凋落物 | 已完成 |
|
||||
| 工单系统 | 用户提交工单 | 已完成 |
|
||||
@@ -22,13 +31,14 @@
|
||||
|
||||
| 前置插件 | 选装类型 | 功能作用 |
|
||||
| :--- | :--- | :--- |
|
||||
| [CY_beibao](https://pan.ypshidifu.cn/s/Ygc0) | 可选,推荐 | 云背包,积分商城可购买背包容量
|
||||
| EssentialsX | 可选 | 主流经济系统
|
||||
| [sdf1](https://github.com/ypsdf1/sdf1_plugn) | 可选,推荐 | CDK兑换系统
|
||||
| [CY_beibao](https://git.ypshidifu.cn/youpaishidifu/CY_beibao) | 可选,推荐 | 云背包,积分商城可购买背包容量 |
|
||||
| EssentialsX | 可选 | 主流经济系统 |
|
||||
| [sdf1](https://git.ypshidifu.cn/youpaishidifu/Sdf1_plugin) | 可选,推荐 | CDK兑换系统 |
|
||||
|
||||
|
||||
|
||||
### 2.1 下载
|
||||
您可以从GitHub和Gitee下载到插件,将其丢入服务器plugin目录,重启服务器即可。
|
||||
您可以从GitHub和Gitee、gitcode下载到插件,将其丢入服务器plugin目录,重启服务器即可。
|
||||
|
||||
### 2.2. 配置文件
|
||||
|
||||
@@ -360,11 +370,21 @@ http-port=443
|
||||
## 2.3 商城部分介绍
|
||||
1. 自助退款,买多买错,不用慌,一件退单很安心<br>
|
||||
2. 购物车,买的多怕买错,先加购物车,后续统一结算<br>
|
||||
3. 害怕订单找不到?一键查看历史订单<br>
|
||||

|
||||
4. 玩家要耍赖皮,拿着小票来找麻烦怎么办?退款订单自动贴标,拒绝白嫖
|
||||
<br>
|
||||

|
||||
5. 想查看玩家消费记录?小手一点,明明白白<br>
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
## 2.4 打印流水部分介绍
|
||||
1. 管理员怀疑某个玩家刷钱了,怎么办?/printer 玩家名,一检查流水,很安心。很多人都刷了怎么办?/printer不加参数,一键导出全服流水,查他个底朝天<br>
|
||||
2. 忘记删除怎么办?插件每分钟自动检测,旧文件超过24小时,自动删
|
||||
|
||||
|
||||
|
||||
|
||||
## 3. 指令参数及介绍
|
||||
@@ -381,6 +401,8 @@ http-port=443
|
||||
- printer 打印流水数据,支持不带参数&带玩家名为参数<br>
|
||||
- sdf1_login printer 作用同printer<br>
|
||||
- sdf1_login shop 商城
|
||||
- protect 区域防护
|
||||
|
||||
|
||||
|
||||
## 4. 自定义菜单物品
|
||||
@@ -401,6 +423,139 @@ http-port=443
|
||||
- 5杀以上每杀一次都是一条播报<br>
|
||||
- 终结者通报,触发连杀的玩家被终结也会有全服通报
|
||||
|
||||
## 6. 区域防护
|
||||
- 通过此功能,您可在服务器里需要防护的地方(比如展示框不想被玩家随便破坏、主城不准玩家袭击等)加以防护。<br>
|
||||
- 还能够保护指定区域的玩家不受任何伤害。<br>
|
||||
|
||||
| 指令 | 参数1| 参数2 | 参数3 | 结果 |
|
||||
| :---: | :---: | :---: | :---: | :---: |
|
||||
| protect | 无 | 无 | 无 | 帮助信息 |
|
||||
| protect | add | 区域名 | 玩家名 | 加白指定玩家到区域 |
|
||||
| protect | add | 玩家名 | 无| 加白指定玩家为全局 |
|
||||
| protect | additem | 区域名 | 物品名 | 加黑指定物品到区域 |
|
||||
| protect | additem | 物品名 | 无 | 加黑指定物品到全局 |
|
||||
| protect | addname | 区域名 | 名字 | 加白指定生物名字到区域和平模式白名单 |
|
||||
| protect | addplayer | 区域名 | 玩家名字 | 加白指定玩家到区域强制模式豁免名单 |
|
||||
| protect | remove | 玩家名字 | 无 | 移除玩家为全白 |
|
||||
| protect | remove | 区域名 | 玩家名 | 移除玩家为区白 |
|
||||
| protect | removeitem | 物品名 | 无 | 删除指定物品为全黑
|
||||
| protect | removeitem | 区域名 | 物品名 | 删除指定物品为区黑
|
||||
| protect | removename | 区域名 | 名字 | 删除指定名字为区域和平模式白名单 |
|
||||
| protect | removeplayer | 区域名 | 名字 | 删除指定玩家为强制模式豁免名单 |
|
||||
| protect | 参数不分顺序 | 区名可以写这里也可以写后面 | 玩家名可以写这里也可以写前面 | 完美的宝宝化 |
|
||||
|
||||
示范:
|
||||
- `/protect add 末地保护区 players` 添加players玩家到末地保护区<br>
|
||||
- `/protect removeitem 主城区 TNT` 从主城删了TNT这个黑名单物品<br>
|
||||
- `/protect 筑城区 addwhite zhangsan` 把zhangsan加白到主城区的模式豁免名单。<br>
|
||||
- 所有参数`任意排序`,`区域名字`随便填写,强大的`兜底`会自动识别"宝宝画板",你可以放心、大胆的乱写
|
||||
|
||||
|
||||
| 参数 | 效果 |
|
||||
| :---: | :---: |
|
||||
| 和平模式 | 所有敌对生物全部进虚空 |
|
||||
| 禁止袭击 | 立即清理玩家身上的袭击效果,传灾厄村民进虚空 |
|
||||
| 清除XX效果 | 立即清除玩家身上的效果
|
||||
| 给与xx效果 | 根据配置文件,给予玩家指定等级、时长的效果
|
||||
|
||||
## 生物属性对照表
|
||||
| 中文名称 | 类型 | 附加属性 |
|
||||
| :---: | :----: | :----: |
|
||||
| 溺尸 | 近战/远程 | 亡灵、水生 |
|
||||
| 骷髅 | 远程 | 亡灵 |
|
||||
| 流浪者 | 远程 | 亡灵、寒冷群系变种 |
|
||||
| 沼骸 | 远程 | 亡灵、沼泽变种 |
|
||||
| 焦骸 | 远程 | 亡灵、沙漠变种、免疫阳光 |
|
||||
| 掠夺者 | 远程 | 灾厄村民、袭击成员 |
|
||||
| 卫道士 | 近战 | 灾厄村民、袭击成员 |
|
||||
| 幻术师 | 远程 | 灾厄村民、Java专属、隐身分身 |
|
||||
| 旋风人 | 远程 | 试炼密室生成、免疫弹射物、强击退 |
|
||||
| 监守者 | 近战/声波远程 | 深暗之域、附带盲视效果 |
|
||||
| 远古守卫者 | 近战/激光远程 | 海底神殿、水下、附加挖掘疲劳 |
|
||||
| 守卫者 | 近战/激光远程 | 海底神殿、水下 |
|
||||
|
||||
|
||||
配置文件解析:<br>
|
||||
- 支持注释格式:`Java的//单行注释`、`yml的#单行注释`、`HTML的<!--- --->注释`和`Java/* */块注释`
|
||||
```txt
|
||||
# 区域: 主城区
|
||||
起点: world,-130,-713
|
||||
终点: world,-427,-560
|
||||
高度范围: 0-255
|
||||
|
||||
没收物品: 蘑菇,毒蘑菇 #没收哪些物品(末地推荐这两个)
|
||||
// 禁止放置方块 #保护区内,非区白/全白玩家禁止放置方块
|
||||
// 禁止破坏方块 # 同上
|
||||
禁止PVP # 玩家之间不能PVP
|
||||
禁止摔伤 # 摔落无法造成任何伤害
|
||||
禁止饥饿 # 不会消耗饱食度
|
||||
禁止一切伤害 # 任何怪物都无法造成伤害
|
||||
# 禁止丢弃物品
|
||||
# 禁止末影珍珠
|
||||
# 禁止使用弓箭
|
||||
# 禁止骑乘
|
||||
// 和平模式 // 所有的敌对生物全部传送进虚空
|
||||
禁止袭击
|
||||
禁止交互展示框 # 核心,任何玩家不能触碰展示框。区白/全白玩家除外
|
||||
// 清除所有效果 # 清理所有负面效果
|
||||
效果 村庄英雄 255 999999 # 给予玩家效果。格式为: 效果 效果名字 等级(最高255) 时间
|
||||
效果 生命提升 255 999999
|
||||
效果 抗性提升 255 999999
|
||||
效果 抗火 255 999999
|
||||
# 禁止药水
|
||||
禁止爆炸
|
||||
禁止使用物品: TNT
|
||||
# 效果: 夜视 1 999
|
||||
进入提示: 欢迎来到保护区 # 是否开启进入提示,即自定义提示
|
||||
离开提示: 已离开保护区
|
||||
没收提示: 你的违禁品已被没收
|
||||
通报批评: §c§l{player} §f在{area}违规携带违禁品,被没收 §e{count}§f件! # 通报违禁品。{player} 玩家名 {count}数量 {area}区域
|
||||
# 惩罚命令: ban {player} 60s 违规
|
||||
|
||||
|
||||
```
|
||||
|
||||
## php端说明:
|
||||
1. 默认密码ypshidifu2026<br>
|
||||
2. 工单系统全面支持markdown语法:
|
||||
```md
|
||||
# 一级标题
|
||||
## 二级标题
|
||||
### 三级标题
|
||||
#### 四级标题
|
||||
##### 五级标题
|
||||
###### 六级标题
|
||||
|
||||
表格语法
|
||||
|
||||
| 表格列1 | 表格列2 |
|
||||
| --- | --- |
|
||||
| 表格1 | 表格2 |
|
||||
|
||||
>单嵌套语法
|
||||
|
||||
>嵌套块
|
||||
>
|
||||
>>语法
|
||||
|
||||
 图片语法
|
||||
[]() 超链接语法
|
||||
<br>换行语法
|
||||
代码块语法
|
||||
```
|
||||
## 工单状态:
|
||||
|
||||
| 状态名 | 是否可回复 | 备注说明 |
|
||||
| :---: | :---: | :---: |
|
||||
| 已提交 | 可回复 | 初始化状态 |
|
||||
| 已回复 | 可回复 | 玩家自己回复了/管理员/服务商回复了 |
|
||||
| 已完结 | 不可回复 | 管理员驳回了工单请求 |
|
||||
| 已撤销 | 不可回复 | 用户撤销了工单请求 |
|
||||
|
||||
## 服务商说明
|
||||
- 若您需要授权某个玩家为服务商,请登录游戏,打开工单系统-管理面板-添加服务商,然后输入他的名字即可邀请服务商
|
||||
|
||||
|
||||
## 运行截图
|
||||
|
||||
主界面
|
||||
@@ -459,7 +614,8 @@ http-port=443
|
||||
商城退单
|
||||

|
||||
|
||||
|
||||
## 4. 下载地址
|
||||
- 官方文档:[https://wiki.ypshidifu.cn](https://wiki.ypshidifu.cn) (推荐)
|
||||
- GitHub:[https://github.com/ypsdf1/sdf1_login](https://github.com/ypsdf1/sdf1_login)
|
||||
- Gitee:[https://gitee.com/nihaoshidifu/sdf1_login](https://gitee.com/nihaoshidifu/sdf1_login)
|
||||
- gitcode: [https://gitcode.com/ypsdf1/Sdf1_login](https://gitcode.com/ypsdf1/Sdf1_login)
|
||||
|
||||
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 109 KiB |
@@ -11,7 +11,7 @@
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<java.version>25</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
@@ -27,19 +27,18 @@
|
||||
</repositories>
|
||||
|
||||
<dependencies>
|
||||
<!-- ★ 与测试服/生产服运行时保持一致(Paper 26.x)。
|
||||
26.x 的命令分发改走 Brigadier:PlayerCommandPreprocessEvent 已
|
||||
@Deprecated 且不再是拦截入口,未命中的唯一拦截点是
|
||||
UnknownCommandEvent;聊天事件是 AsyncChatEvent 而非
|
||||
AsyncPlayerChatEvent。用 1.21.4 编译会因签名不匹配直接编不过。 -->
|
||||
<dependency>
|
||||
<groupId>io.papermc.paper</groupId>
|
||||
<artifactId>paper-api</artifactId>
|
||||
<version>1.21.4-R0.1-SNAPSHOT</version>
|
||||
<version>26.2.build.129-stable</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.github.MilkBowl</groupId>
|
||||
<artifactId>VaultAPI</artifactId>
|
||||
<version>1.7.1</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.xerial</groupId>
|
||||
<artifactId>sqlite-jdbc</artifactId>
|
||||
@@ -55,10 +54,10 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<version>3.15.0</version>
|
||||
<configuration>
|
||||
<source>21</source>
|
||||
<target>21</target>
|
||||
<release>25</release>
|
||||
<encoding>UTF-8</encoding>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
|
||||
@@ -95,11 +95,24 @@ public class AccountRequestManager {
|
||||
public boolean approveRequest(int id) {
|
||||
Request req = requests.get(id);
|
||||
if (req == null || !"PENDING".equals(req.status)) return false;
|
||||
// ★ 精准匹配:TEST 与 test 是两个人,名字必须完全相等才删
|
||||
if (plugin.getDb().findExactUserName(req.target) == null) {
|
||||
String twin = plugin.getDb()
|
||||
.findSimilarUserNameIgnoreCase(req.target);
|
||||
System.out.println("[Sdf1_login] 删号申请 #"+ id
|
||||
+ " 未执行:账号 " + req.target
|
||||
+ (twin != null ? " 不存在,仅存在大小写不同的账号 " + twin : " 不存在")
|
||||
+ "(精准匹配,未误删)");
|
||||
return false;
|
||||
}
|
||||
int deleted = plugin.getDb().deleteUser(req.target);
|
||||
if (deleted <= 0) {
|
||||
return false;
|
||||
}
|
||||
req.status = "APPROVED";
|
||||
plugin.getDb().deleteUser(req.target);
|
||||
pendingByPlayer.remove(req.requester);
|
||||
// 清背包+传送出生点+踢出
|
||||
Player target = Bukkit.getPlayer(req.target);
|
||||
// 清背包+传送出生点+踢出(★ getPlayerExact:避免误伤大小写不同的在线玩家)
|
||||
Player target = Bukkit.getPlayerExact(req.target);
|
||||
if (target != null && target.isOnline()) {
|
||||
target.getInventory().clear();
|
||||
target.getInventory().setArmorContents(new org.bukkit.inventory.ItemStack[4]);
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.advancement.Advancement;
|
||||
import org.bukkit.advancement.AdvancementProgress;
|
||||
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.EntityPotionEffectEvent;
|
||||
import com.destroystokyo.paper.event.player.PlayerAdvancementCriterionGrantEvent;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* ★ 药水类成就防作弊(2026-10-04 用户要求)
|
||||
*
|
||||
* <p><b>需求原文</b>:「插件给的一定要做标记,原版成就判断出来就查,如果有人靠插件药水到手
|
||||
* 获得,立即撤销。……两条关联成就必须要玩家手打。绝不能靠插件作弊。」</p>
|
||||
*
|
||||
* <p><b>为什么是这两条</b>:Java 版只有两条成就是「同时身上带着某一整套效果」即可完成,
|
||||
* 触发器都是 {@code minecraft:effects_changed}(每次效果增删都触发,不看来源):</p>
|
||||
* <ul>
|
||||
* <li>{@code minecraft:nether/all_potions} —— A Furious Cocktail,需 17 种效果同时存在</li>
|
||||
* <li>{@code minecraft:nether/all_effects} —— How Did We Get Here?,需 34 种效果同时存在</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>为什么要撤销而不是拦 toast</b>:Toast 推送由客户端与服务端共同完成,
|
||||
* 单靠服务端无法保证「不弹」,但可以在授予的同一 tick 内撤销进度——
|
||||
* 玩家在成绩界面看到的就是「未完成」,这就满足了需求里的「服务端撤销即可」。</p>
|
||||
*
|
||||
* <p><b>判定口径</b>:只要玩家当前身上的效果集合里,<b>有任意一项</b>是本插件给出去的
|
||||
* (即带插件标记),就撤销这两条成就。<b>不撤销别的成就</b>——只有这两条的判定完全由
|
||||
* 效果集合构成,其他成就(酿造药水、击杀生物、探险家等)跟插件效果无关,误伤代价大。</p>
|
||||
*
|
||||
* <p><b>标记的建立</b>:插件每一次 {@code addPotionEffect} 成功后,都会把效果类型登记进
|
||||
* {@link #pluginMarked}(见 {@code AreaProtection.markPluginEffects})。这份标记在玩家退出
|
||||
* 领地、效果自然到期时统一清理,避免「插件给过一次就永久背锅」。</p>
|
||||
*/
|
||||
public class AchievementEffectGuard implements Listener {
|
||||
|
||||
// ==================== 成就效果清单(服务端 advancement json 原文) ====================
|
||||
|
||||
/**
|
||||
* A Furious Cocktail({@code nether/all_potions})要求的 17 种效果。
|
||||
* 取自 {@code data/minecraft/advancement/nether/all_potions.json} 的 criteria.effects 键集。
|
||||
*/
|
||||
private static final Set<String> ADV_SET_ALL_POTIONS = Set.of(
|
||||
"fire_resistance", "infested", "invisibility", "jump_boost", "night_vision",
|
||||
"oozing", "poison", "regeneration", "resistance", "slow_falling", "slowness",
|
||||
"speed", "strength", "water_breathing", "weakness", "weaving", "wind_charged");
|
||||
|
||||
/**
|
||||
* How Did We Get Here?({@code nether/all_effects})要求的 34 种效果。
|
||||
* 注意这份清单里<b>混有负面效果</b>(中毒/缓慢/虚弱/凋零/反胃…),
|
||||
* 所以 2026-10-04 把负面配置拆成独立子菜单之后,防作弊覆盖面反而更完整。
|
||||
*/
|
||||
private static final Set<String> ADV_SET_ALL_EFFECTS = Set.of(
|
||||
"absorption", "bad_omen", "blindness", "breath_of_the_nautilus", "conduit_power",
|
||||
"darkness", "dolphins_grace", "fire_resistance", "glowing", "haste",
|
||||
"hero_of_the_village", "hunger", "infested", "invisibility", "jump_boost",
|
||||
"levitation", "mining_fatigue", "nausea", "night_vision", "oozing", "poison",
|
||||
"raid_omen", "regeneration", "resistance", "slow_falling", "slowness", "speed",
|
||||
"strength", "trial_omen", "water_breathing", "weakness", "weaving", "wind_charged",
|
||||
"wither");
|
||||
|
||||
/** 两条成就的 key(延迟解析,插件早期启动时 advancement 尚未加载完) */
|
||||
private static final String ADV_KEY_ALL_POTIONS = "minecraft:nether/all_potions";
|
||||
private static final String ADV_KEY_ALL_EFFECTS = "nether/all_effects";
|
||||
|
||||
private final Main plugin;
|
||||
|
||||
/** 插件给出去的效果标记:玩家 UUID → 本插件施加过的效果类型名(小写英文 ID) */
|
||||
private final Map<UUID, Set<String>> pluginMarked = new HashMap<>();
|
||||
|
||||
/** 已完成撤销的玩家,避免每次事件都重复刷日志 */
|
||||
private final Set<UUID> revokedLogged = new HashSet<>();
|
||||
|
||||
/** 每个玩家的判定冷却:同一玩家 1 秒内最多判一次,防抖 */
|
||||
private final Map<UUID, Long> lastCheckTs = new HashMap<>();
|
||||
|
||||
private static final long CHECK_COOLDOWN_MS = 1000L;
|
||||
|
||||
public AchievementEffectGuard(Main plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
// ==================== 标记接口(供 AreaProtection 调用) ====================
|
||||
|
||||
/**
|
||||
* 登记「这些效果是插件给的」。
|
||||
*
|
||||
* @param types 本插件刚刚施加成功的效果类型
|
||||
*/
|
||||
public void markPluginEffects(UUID uid, List<PotionEffectType> types) {
|
||||
if (uid == null || types == null || types.isEmpty()) return;
|
||||
Set<String> set = pluginMarked.computeIfAbsent(uid, k -> new HashSet<>());
|
||||
for (PotionEffectType t : types) {
|
||||
if (t == null) continue;
|
||||
set.add(normalizeEffectId(t));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清掉某玩家的插件效果标记(离开领地 / 插件不再持有该效果时调用)。
|
||||
*
|
||||
* @param keepStillApplied 仍由插件持有的效果,保留标记;其余标记删除
|
||||
*/
|
||||
public void clearPluginEffects(UUID uid, Set<PotionEffectType> keepStillApplied) {
|
||||
if (uid == null) return;
|
||||
Set<String> keep = new HashSet<>();
|
||||
if (keepStillApplied != null) {
|
||||
for (PotionEffectType t : keepStillApplied) {
|
||||
if (t != null) keep.add(normalizeEffectId(t));
|
||||
}
|
||||
}
|
||||
Set<String> cur = pluginMarked.get(uid);
|
||||
if (cur == null) return;
|
||||
cur.retainAll(keep);
|
||||
if (cur.isEmpty()) pluginMarked.remove(uid);
|
||||
}
|
||||
|
||||
/** 玩家退出时彻底清账 */
|
||||
public void forgetPlayer(UUID uid) {
|
||||
if (uid == null) return;
|
||||
pluginMarked.remove(uid);
|
||||
revokedLogged.remove(uid);
|
||||
lastCheckTs.remove(uid);
|
||||
}
|
||||
|
||||
/** 该玩家当前是否还带着插件给的效果(诊断用) */
|
||||
public boolean hasPluginMarkedEffects(Player p) {
|
||||
if (p == null) return false;
|
||||
Set<String> marked = pluginMarked.get(p.getUniqueId());
|
||||
if (marked == null || marked.isEmpty()) return false;
|
||||
for (PotionEffect eff : p.getActivePotionEffects()) {
|
||||
if (marked.contains(normalizeEffectId(eff.getType()))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==================== 事件监听 ====================
|
||||
|
||||
/**
|
||||
* 每次效果变化都复核一遍。
|
||||
*
|
||||
* <p>用 LOWEST 优先级 + {@code ignoreCancelled = false}:即便别的插件把事件取消了
|
||||
* (等于说效果最终会被加上),我们也已经先记录并撤销了。</p>
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
|
||||
public void onPotionEffectChange(EntityPotionEffectEvent event) {
|
||||
if (!(event.getEntity() instanceof Player)) return;
|
||||
Player p = (Player) event.getEntity();
|
||||
|
||||
// 插件自己施加的:立刻打标记(与 AreaProtection 的登记互为兜底)
|
||||
if (event.getCause() == EntityPotionEffectEvent.Cause.PLUGIN
|
||||
&& event.getAction() != EntityPotionEffectEvent.Action.CLEARED
|
||||
&& event.getAction() != EntityPotionEffectEvent.Action.REMOVED) {
|
||||
PotionEffectType type = event.getModifiedType();
|
||||
if (type != null) {
|
||||
markPluginEffects(p.getUniqueId(), Collections.singletonList(type));
|
||||
}
|
||||
}
|
||||
|
||||
// 效果被移除/清除时,同步把标记里已经不生效的项剔掉
|
||||
if (event.getAction() == EntityPotionEffectEvent.Action.CLEARED) {
|
||||
pluginMarked.remove(p.getUniqueId());
|
||||
} else if (event.getAction() == EntityPotionEffectEvent.Action.REMOVED) {
|
||||
Set<String> marked = pluginMarked.get(p.getUniqueId());
|
||||
if (marked != null && event.getModifiedType() != null) {
|
||||
marked.remove(normalizeEffectId(event.getModifiedType()));
|
||||
if (marked.isEmpty()) pluginMarked.remove(p.getUniqueId());
|
||||
}
|
||||
}
|
||||
|
||||
scheduleCheck(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* 成就授予的那一刻直接撤销(兜底,覆盖 effects_changed 之外的授予路径)。
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void onAdvancementCriterionGrant(PlayerAdvancementCriterionGrantEvent event) {
|
||||
Advancement adv = event.getAdvancement();
|
||||
if (adv == null || adv.getKey() == null) return;
|
||||
String key = adv.getKey().toString(); // 形如 nether/all_potions
|
||||
if (!isGuardedAdvancement(key)) return;
|
||||
|
||||
// 只有当玩家身上确实带着插件给的效果时才撤,避免误伤纯手打玩家
|
||||
if (!hasPluginMarkedEffects(event.getPlayer())) return;
|
||||
|
||||
event.setCancelled(true);
|
||||
revokeAll(event.getPlayer(), key, "成就授予时检测到插件来源效果");
|
||||
}
|
||||
|
||||
/** 玩家进服立刻复核一次(防止插件重启期间拿到成就、或上次下线前漏撤) */
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
// 延后一 tick:刚进服时 effect / advancement 数据可能还没完全同步
|
||||
Bukkit.getScheduler().runTaskLater(plugin, () -> {
|
||||
Player p = event.getPlayer();
|
||||
if (p != null && p.isOnline()) {
|
||||
checkAndRevoke(p, false);
|
||||
}
|
||||
}, 20L);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onQuit(PlayerQuitEvent event) {
|
||||
forgetPlayer(event.getPlayer().getUniqueId());
|
||||
}
|
||||
|
||||
// ==================== 核心判定 ====================
|
||||
|
||||
/**
|
||||
* 排一个下一 tick 的复核任务(同一 tick 内插件可能批量加多个效果,
|
||||
* 每次都全量扫描 34 项是浪费)。
|
||||
*/
|
||||
private void scheduleCheck(Player p) {
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
if (p != null && p.isOnline()) {
|
||||
checkAndRevoke(p, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定并撤销。
|
||||
*
|
||||
* @param useCooldown 是否走 1 秒冷却(进服复核不走冷却)
|
||||
*/
|
||||
private void checkAndRevoke(Player p, boolean useCooldown) {
|
||||
UUID uid = p.getUniqueId();
|
||||
long now = System.currentTimeMillis();
|
||||
if (useCooldown) {
|
||||
Long last = lastCheckTs.get(uid);
|
||||
if (last != null && now - last < CHECK_COOLDOWN_MS) return;
|
||||
lastCheckTs.put(uid, now);
|
||||
}
|
||||
|
||||
Set<String> marked = pluginMarked.get(uid);
|
||||
if (marked == null || marked.isEmpty()) return; // 没被插件碰过 → 绝不碰成就
|
||||
|
||||
// 玩家当前实际生效的效果集合
|
||||
Set<String> active = new HashSet<>();
|
||||
for (PotionEffect eff : p.getActivePotionEffects()) {
|
||||
active.add(normalizeEffectId(eff.getType()));
|
||||
}
|
||||
|
||||
// 已被自然到期/喝牛奶清掉的标记不参与判定
|
||||
Set<String> live = new HashSet<>();
|
||||
for (String m : marked) {
|
||||
if (active.contains(m)) live.add(m);
|
||||
}
|
||||
if (live.isEmpty()) {
|
||||
pluginMarked.remove(uid);
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 关键判定:两条成就要求的清单里,只要有任意一项在 live 里 → 撤销
|
||||
List<String> hitAllPotions = new ArrayList<>();
|
||||
for (String need : ADV_SET_ALL_POTIONS) {
|
||||
if (live.contains(need)) hitAllPotions.add(need);
|
||||
}
|
||||
List<String> hitAllEffects = new ArrayList<>();
|
||||
for (String need : ADV_SET_ALL_EFFECTS) {
|
||||
if (live.contains(need)) hitAllEffects.add(need);
|
||||
}
|
||||
|
||||
if (!hitAllPotions.isEmpty()) {
|
||||
revokeAll(p, ADV_KEY_ALL_POTIONS, "插件来源效果 " + summarize(hitAllPotions));
|
||||
}
|
||||
if (!hitAllEffects.isEmpty()) {
|
||||
revokeAll(p, ADV_KEY_ALL_EFFECTS, "插件来源效果 " + summarize(hitAllEffects));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销一条受管控成就的全部条件。
|
||||
*
|
||||
* <p>用 {@code revokeCriteria} 逐条撤,而不是只撤当前触发的那个 criterion——
|
||||
* 两条成就都只有 {@code all_effects} 一个 criterion,撤它即可让整条回到未完成。</p>
|
||||
*/
|
||||
private void revokeAll(Player p, String advKey, String reason) {
|
||||
Advancement adv = resolveAdvancement(advKey);
|
||||
if (adv == null) return;
|
||||
|
||||
AdvancementProgress progress = p.getAdvancementProgress(adv);
|
||||
if (progress == null || !progress.isDone()) return; // 本来就没完成,不动
|
||||
|
||||
Set<String> remaining = new HashSet<>(progress.getRemainingCriteria());
|
||||
int revoked = 0;
|
||||
for (String crit : progress.getAwardedCriteria()) {
|
||||
if (progress.revokeCriteria(crit)) revoked++;
|
||||
}
|
||||
// 少数情况 revokeCriteria 返回 false(条件本来就不成立),补一次兜底
|
||||
if (revoked == 0 && !remaining.isEmpty()) {
|
||||
for (String crit : progress.getRemainingCriteria()) {
|
||||
remaining.remove(crit);
|
||||
}
|
||||
}
|
||||
|
||||
if (revokedLogged.add(p.getUniqueId())) {
|
||||
plugin.getLogger().warning("[成就防作弊] 已撤销 " + advKey
|
||||
+ " 玩家=" + p.getName()
|
||||
+ " 原因=" + reason
|
||||
+ " 撤销条件数=" + revoked
|
||||
+ "(插件提供方便,不提供作弊;这两条成就必须手打)");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 advancement:优先用 NamespacedKey({@code Bukkit.getAdvancement}),
|
||||
* 失败则回退成 Bukkit 可识别的短 key。
|
||||
*/
|
||||
private Advancement resolveAdvancement(String key) {
|
||||
String plain = key.startsWith("minecraft:") ? key.substring("minecraft:".length()) : key;
|
||||
Advancement adv = null;
|
||||
try {
|
||||
adv = Bukkit.getAdvancement(NamespacedKey.minecraft(plain));
|
||||
} catch (Throwable ignored) { }
|
||||
if (adv == null) {
|
||||
try {
|
||||
adv = Bukkit.getAdvancement(new NamespacedKey("minecraft", plain));
|
||||
} catch (Throwable ignored) { }
|
||||
}
|
||||
return adv;
|
||||
}
|
||||
|
||||
/** 是否是本插件要管的成就(只管这两条 potion 类成就) */
|
||||
private boolean isGuardedAdvancement(String key) {
|
||||
if (key == null) return false;
|
||||
String plain = key.startsWith("minecraft:") ? key.substring("minecraft:".length()) : key;
|
||||
return plain.equals(ADV_KEY_ALL_POTIONS) || plain.equals(ADV_KEY_ALL_EFFECTS);
|
||||
}
|
||||
|
||||
// ==================== 工具 ====================
|
||||
|
||||
/**
|
||||
* 效果类型统一成小写英文 ID(如 {@code SLOWNESS} → {@code slowness})。
|
||||
*
|
||||
* <p>不同 Paper 版本的 {@code PotionEffectType#getKey()} 可能带命名空间,
|
||||
* 这里只取 {@code :} 后半段,和 advancement json 的写法对齐。</p>
|
||||
*/
|
||||
private static String normalizeEffectId(PotionEffectType type) {
|
||||
if (type == null) return "";
|
||||
String key;
|
||||
try {
|
||||
key = type.getKey().toString(); // 形如 minecraft:speed
|
||||
} catch (Throwable t) {
|
||||
key = type.getName(); // 老版本退路
|
||||
}
|
||||
if (key == null) return "";
|
||||
int idx = key.indexOf(':');
|
||||
if (idx >= 0) key = key.substring(idx + 1);
|
||||
return key.toLowerCase(java.util.Locale.ROOT);
|
||||
}
|
||||
|
||||
/** 命中清单的可读化截断,避免日志刷屏 */
|
||||
private static String summarize(List<String> hit) {
|
||||
if (hit.size() <= 6) return String.join(",", hit);
|
||||
return String.join(",", hit.subList(0, 6)) + " …共" + hit.size() + "项";
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,26 @@ public class BondManager {
|
||||
private final Main plugin;
|
||||
private Connection db;
|
||||
|
||||
// ===== 交易监听器 =====
|
||||
public interface TransactionListener {
|
||||
void onTransaction(String playerName, String type, int amount, String targetPlayer);
|
||||
}
|
||||
private final List<TransactionListener> transactionListeners = new ArrayList<>();
|
||||
|
||||
public void addTransactionListener(TransactionListener listener) {
|
||||
transactionListeners.add(listener);
|
||||
}
|
||||
|
||||
private void fireTransactionEvent(String playerName, String type, int amount, String targetPlayer) {
|
||||
for (TransactionListener listener : transactionListeners) {
|
||||
try {
|
||||
listener.onTransaction(playerName, type, amount, targetPlayer);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 交易类型常量 =====
|
||||
public static final String TX_ADMIN_GIVE = "admin_give";
|
||||
public static final String TX_ADMIN_DEDUCT = "admin_deduct";
|
||||
@@ -21,6 +41,9 @@ public class BondManager {
|
||||
public static final String TX_DAILY_SIGN = "daily_sign";
|
||||
public static final String TX_FREEZE = "freeze";
|
||||
public static final String TX_UNFREEZE = "unfreeze";
|
||||
public static final String TX_TICKET_REWARD = "ticket_reward";
|
||||
public static final String TX_TICKET_SERVICE = "ticket_service";
|
||||
|
||||
|
||||
public static final int MAX_WATER_DAYS = 14;
|
||||
public static final int ADMIN_MAX_WATER_DAYS = 30;
|
||||
@@ -36,17 +59,20 @@ public class BondManager {
|
||||
try {
|
||||
Class.forName("org.sqlite.JDBC");
|
||||
File dbFile = new File(
|
||||
plugin.getDataFolder(), "bond.db");
|
||||
new File(plugin.getDataFolder(), "财务债券"),
|
||||
"bond.db");
|
||||
dbFile.getParentFile().mkdirs();
|
||||
db = DriverManager.getConnection(
|
||||
"jdbc:sqlite:" + dbFile.getAbsolutePath());
|
||||
Statement st = db.createStatement();
|
||||
st.execute("PRAGMA journal_mode=WAL");
|
||||
|
||||
// 债券余额(含冻结状态)
|
||||
// 债券余额(含冻结状态 + 冻结到期时间)
|
||||
st.execute("CREATE TABLE IF NOT EXISTS bonds ("
|
||||
+ "player_name TEXT PRIMARY KEY,"
|
||||
+ "amount INTEGER DEFAULT 0,"
|
||||
+ "status TEXT DEFAULT 'normal')");
|
||||
+ "status TEXT DEFAULT 'normal',"
|
||||
+ "freeze_until INTEGER DEFAULT 0)");
|
||||
|
||||
// 兼容旧表
|
||||
try {
|
||||
@@ -54,6 +80,12 @@ public class BondManager {
|
||||
+ "ADD COLUMN status "
|
||||
+ "TEXT DEFAULT 'normal'");
|
||||
} catch (SQLException ignored) {}
|
||||
// ★ 2026-10-04:冻结到期时间(Unix 毫秒,0=永久冻结)
|
||||
try {
|
||||
st.execute("ALTER TABLE bonds "
|
||||
+ "ADD COLUMN freeze_until "
|
||||
+ "INTEGER DEFAULT 0");
|
||||
} catch (SQLException ignored) {}
|
||||
|
||||
// 旧流水(保留兼容)
|
||||
st.execute("CREATE TABLE IF NOT EXISTS bond_log ("
|
||||
@@ -99,47 +131,111 @@ public class BondManager {
|
||||
|
||||
// ======================== 账户状态 ========================
|
||||
|
||||
/**
|
||||
* 账户是否处于冻结状态。
|
||||
*
|
||||
* <p>★ 2026-10-04 支持临时冻结:status='frozen' 且
|
||||
* {@code freeze_until>0} 时,到期自动解冻并回写 status='normal';
|
||||
* {@code freeze_until=0} 表示永久冻结。
|
||||
*/
|
||||
public boolean isFrozen(String player) {
|
||||
boolean frozen = false;
|
||||
long until = 0L;
|
||||
try {
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"SELECT status FROM bonds "
|
||||
"SELECT status, freeze_until FROM bonds "
|
||||
+ "WHERE player_name=?");
|
||||
ps.setString(1, player);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
if (rs.next()) {
|
||||
String s = rs.getString("status");
|
||||
rs.close(); ps.close();
|
||||
return "frozen".equals(s);
|
||||
frozen = "frozen".equals(rs.getString("status"));
|
||||
until = rs.getLong("freeze_until");
|
||||
}
|
||||
rs.close(); ps.close();
|
||||
} catch (SQLException ignored) {}
|
||||
return false;
|
||||
} catch (SQLException ignored) {
|
||||
return false;
|
||||
}
|
||||
if (!frozen) return false;
|
||||
if (until > 0L && until <= System.currentTimeMillis()) {
|
||||
unfreezeAccount(player); // 临时冻结到期 -> 自动解冻
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 冻结到期时间(Unix 毫秒);0=永久冻结;-1=未冻结 */
|
||||
public long getFreezeUntil(String player) {
|
||||
try {
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"SELECT status, freeze_until FROM bonds "
|
||||
+ "WHERE player_name=?");
|
||||
ps.setString(1, player);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
long until = -1L;
|
||||
if (rs.next()) {
|
||||
if (!"frozen".equals(rs.getString("status"))) {
|
||||
rs.close(); ps.close();
|
||||
return -1L;
|
||||
}
|
||||
until = rs.getLong("freeze_until");
|
||||
}
|
||||
rs.close(); ps.close();
|
||||
if (until > 0L && until <= System.currentTimeMillis()) {
|
||||
unfreezeAccount(player);
|
||||
return -1L;
|
||||
}
|
||||
return until;
|
||||
} catch (SQLException ignored) {
|
||||
return -1L;
|
||||
}
|
||||
}
|
||||
|
||||
public String getAccountStatus(String player) {
|
||||
String s = "normal";
|
||||
long until = 0L;
|
||||
try {
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"SELECT status FROM bonds "
|
||||
"SELECT status, freeze_until FROM bonds "
|
||||
+ "WHERE player_name=?");
|
||||
ps.setString(1, player);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
if (rs.next()) {
|
||||
String s = rs.getString("status");
|
||||
rs.close(); ps.close();
|
||||
return s != null ? s : "normal";
|
||||
s = rs.getString("status");
|
||||
until = rs.getLong("freeze_until");
|
||||
if (s == null) s = "normal";
|
||||
}
|
||||
rs.close(); ps.close();
|
||||
} catch (SQLException ignored) {}
|
||||
return "normal";
|
||||
} catch (SQLException ignored) {
|
||||
return "normal";
|
||||
}
|
||||
if ("frozen".equals(s)
|
||||
&& until > 0L
|
||||
&& until <= System.currentTimeMillis()) {
|
||||
unfreezeAccount(player); // 到期自动解冻
|
||||
return "normal";
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 永久冻结 */
|
||||
public void freezeAccount(String player) {
|
||||
freezeAccount(player, 0L);
|
||||
}
|
||||
|
||||
/**
|
||||
* 冻结账户(★ 2026-10-04 支持到期时间)。
|
||||
*
|
||||
* @param expireMs Unix 毫秒到期时间;{@code <=0} 表示永久冻结
|
||||
*/
|
||||
public void freezeAccount(String player, long expireMs) {
|
||||
ensureAccount(player);
|
||||
try {
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"UPDATE bonds SET status='frozen' "
|
||||
"UPDATE bonds SET status='frozen', "
|
||||
+ "freeze_until=? "
|
||||
+ "WHERE player_name=?");
|
||||
ps.setString(1, player);
|
||||
ps.setLong(1, expireMs > 0L ? expireMs : 0L);
|
||||
ps.setString(2, player);
|
||||
ps.executeUpdate(); ps.close();
|
||||
} catch (SQLException e) { e.printStackTrace(); }
|
||||
}
|
||||
@@ -148,7 +244,8 @@ public class BondManager {
|
||||
ensureAccount(player);
|
||||
try {
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"UPDATE bonds SET status='normal' "
|
||||
"UPDATE bonds SET status='normal', "
|
||||
+ "freeze_until=0 "
|
||||
+ "WHERE player_name=?");
|
||||
ps.setString(1, player);
|
||||
ps.executeUpdate(); ps.close();
|
||||
@@ -182,6 +279,23 @@ public class BondManager {
|
||||
} catch (SQLException e) { return 0; }
|
||||
}
|
||||
|
||||
/** 该玩家是否在 bond 表中有真实记录 */
|
||||
public boolean hasBondAccount(String player) {
|
||||
try {
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"SELECT 1 FROM bonds "
|
||||
+ "WHERE player_name=? "
|
||||
+ "AND amount > 0");
|
||||
ps.setString(1, player);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
boolean exists = rs.next();
|
||||
rs.close(); ps.close();
|
||||
return exists;
|
||||
} catch (SQLException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ======================== 增加债券 ========================
|
||||
|
||||
public int addBonds(String player, int amount) {
|
||||
@@ -257,7 +371,8 @@ public class BondManager {
|
||||
logTransaction(player, type, amount,
|
||||
targetPlayer, operator, reason,
|
||||
before, after);
|
||||
logLegacy(player, -amount);
|
||||
// 记录扣除流水,use positive amount, UI uses type to determine sign
|
||||
logLegacy(player, amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -272,6 +387,53 @@ public class BondManager {
|
||||
int balanceBefore,
|
||||
int balanceAfter) {
|
||||
try {
|
||||
// 合并模式:shop_buy 30秒内同商品合并
|
||||
if ("shop_buy".equals(type)
|
||||
&& reason != null
|
||||
&& !reason.isEmpty()) {
|
||||
long mergeWindow =
|
||||
System.currentTimeMillis() - 30000L;
|
||||
PreparedStatement chk =
|
||||
db.prepareStatement(
|
||||
"SELECT id, amount "
|
||||
+ "FROM bond_transaction "
|
||||
+ "WHERE player_name=? "
|
||||
+ "AND type=? "
|
||||
+ "AND reason=? "
|
||||
+ "AND time > ? "
|
||||
+ "ORDER BY time DESC "
|
||||
+ "LIMIT 1");
|
||||
chk.setString(1, player);
|
||||
chk.setString(2, type);
|
||||
chk.setString(3, reason);
|
||||
chk.setLong(4, mergeWindow);
|
||||
ResultSet rs = chk.executeQuery();
|
||||
if (rs.next()) {
|
||||
int existId = rs.getInt("id");
|
||||
int existAmt = rs.getInt("amount");
|
||||
rs.close();
|
||||
chk.close();
|
||||
PreparedStatement upd =
|
||||
db.prepareStatement(
|
||||
"UPDATE bond_transaction "
|
||||
+ "SET amount=?, "
|
||||
+ "balance_after=?, "
|
||||
+ "time=? "
|
||||
+ "WHERE id=?");
|
||||
upd.setInt(1, existAmt + amount);
|
||||
upd.setInt(2, balanceAfter);
|
||||
upd.setLong(3,
|
||||
System.currentTimeMillis());
|
||||
upd.setInt(4, existId);
|
||||
upd.executeUpdate();
|
||||
upd.close();
|
||||
return;
|
||||
}
|
||||
rs.close();
|
||||
chk.close();
|
||||
}
|
||||
|
||||
// 原始插入
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"INSERT INTO bond_transaction "
|
||||
+ "(player_name, type, amount,"
|
||||
@@ -283,18 +445,18 @@ public class BondManager {
|
||||
ps.setString(2, type);
|
||||
ps.setInt(3, amount);
|
||||
ps.setString(4,
|
||||
targetPlayer != null
|
||||
? targetPlayer : "");
|
||||
targetPlayer != null ? targetPlayer : "");
|
||||
ps.setString(5,
|
||||
operator != null ? operator : "");
|
||||
ps.setString(6,
|
||||
reason != null ? reason : "");
|
||||
ps.setInt(7, balanceBefore);
|
||||
ps.setInt(8, balanceAfter);
|
||||
ps.setLong(9,
|
||||
System.currentTimeMillis());
|
||||
ps.setLong(9, System.currentTimeMillis());
|
||||
ps.executeUpdate();
|
||||
ps.close();
|
||||
// 触发交易监听器
|
||||
fireTransactionEvent(player, type, amount, targetPlayer);
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
@@ -314,6 +476,72 @@ public class BondManager {
|
||||
type, targetPlayer, operator, reason);
|
||||
}
|
||||
|
||||
// ===== 冻结豁免通道(★ 2026-10-04 冻结规则定案) =====
|
||||
// 冻结期间「仅允许 PHP 后端动账 + 管理员手动 give/remove」,
|
||||
// 玩家主动消费(商城/领地/商店)与他人转账一律被上面的
|
||||
// isFrozen 拦掉;只有走下面两个豁免方法的调用方才能动账。
|
||||
|
||||
/** 管理员/PHP 后台加款:冻结期也放行(只记流水,不查冻结) */
|
||||
public int addBondsAdmin(String player, int amount,
|
||||
String type,
|
||||
String targetPlayer,
|
||||
String operator,
|
||||
String reason) {
|
||||
return addBondsInternal(player, amount,
|
||||
type, targetPlayer, operator, reason);
|
||||
}
|
||||
|
||||
/** 管理员/PHP 后台扣款:冻结期也放行(管理员手动 remove) */
|
||||
public boolean deductBondsAdmin(String player, int amount,
|
||||
String type,
|
||||
String targetPlayer,
|
||||
String operator,
|
||||
String reason) {
|
||||
return deductBondsInternal(player, amount,
|
||||
type, targetPlayer, operator, reason);
|
||||
}
|
||||
|
||||
public boolean checkoutCart(String player,
|
||||
String[] itemNames,
|
||||
int[] subtotals) {
|
||||
int total = 0;
|
||||
for (int s : subtotals) total += s;
|
||||
if (isFrozen(player)) return false;
|
||||
|
||||
int before = getBonds(player);
|
||||
if (before < total) return false;
|
||||
int after = before - total;
|
||||
|
||||
try {
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"INSERT INTO bonds "
|
||||
+ "(player_name,amount,status) "
|
||||
+ "VALUES (?,?,'normal') "
|
||||
+ "ON CONFLICT(player_name) "
|
||||
+ "DO UPDATE SET amount=?");
|
||||
ps.setString(1, player);
|
||||
ps.setInt(2, after);
|
||||
ps.setInt(3, after);
|
||||
ps.executeUpdate();
|
||||
ps.close();
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
|
||||
int running = before;
|
||||
for (int i = 0; i < itemNames.length; i++) {
|
||||
int next = running - subtotals[i];
|
||||
logTransaction(player, "shop_buy", subtotals[i],
|
||||
"", "商店系统",
|
||||
"购物车购买-" + itemNames[i],
|
||||
running, next);
|
||||
running = next;
|
||||
}
|
||||
|
||||
logLegacy(player, -total);
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean setBonds(String player, int amount) {
|
||||
ensureAccount(player);
|
||||
@@ -590,4 +818,221 @@ public class BondManager {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 获取玩家最近1小时出售总额 =====
|
||||
public int getTodaySellTotal(String player) {
|
||||
return getSellTotalInWindow(player, System.currentTimeMillis() - 3600000L);
|
||||
}
|
||||
|
||||
private int getSellTotalInWindow(String player, long since) {
|
||||
try {
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM bond_transaction "
|
||||
+ "WHERE player_name=? AND type='shop_sell' AND time >= ?");
|
||||
ps.setString(1, player);
|
||||
ps.setLong(2, since);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
int total = 0;
|
||||
if (rs.next()) {
|
||||
total = rs.getInt(1);
|
||||
}
|
||||
rs.close();
|
||||
ps.close();
|
||||
return total;
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取玩家最近1小时出售窗口中最早交易的时间戳。
|
||||
* 返回0表示窗口内无交易。
|
||||
*/
|
||||
public long getEarliestSellTimeInWindow(String player) {
|
||||
long since = System.currentTimeMillis() - 3600000L;
|
||||
try {
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"SELECT MIN(time) FROM bond_transaction "
|
||||
+ "WHERE player_name=? AND type='shop_sell' AND time >= ?");
|
||||
ps.setString(1, player);
|
||||
ps.setLong(2, since);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
long earliest = 0;
|
||||
if (rs.next()) {
|
||||
earliest = rs.getLong(1);
|
||||
}
|
||||
rs.close();
|
||||
ps.close();
|
||||
return earliest;
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 交易流水总笔数(用于开服与PHP对账)
|
||||
*/
|
||||
public long getTxCount() {
|
||||
try {
|
||||
Statement st = db.createStatement();
|
||||
ResultSet rs = st.executeQuery("SELECT COUNT(*) FROM bond_transaction");
|
||||
long n = rs.next() ? rs.getLong(1) : 0L;
|
||||
rs.close(); st.close();
|
||||
return n;
|
||||
} catch (SQLException e) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 交易流水最大序列号(bond_transaction.id 自增主键,即对账用的「交易序列号」)
|
||||
*/
|
||||
public long getMaxTxId() {
|
||||
try {
|
||||
Statement st = db.createStatement();
|
||||
ResultSet rs = st.executeQuery("SELECT COALESCE(MAX(id), 0) FROM bond_transaction");
|
||||
long n = rs.next() ? rs.getLong(1) : 0L;
|
||||
rs.close(); st.close();
|
||||
return n;
|
||||
} catch (SQLException e) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定时间之后的所有交易记录(用于Web同步)
|
||||
*/
|
||||
public List<Map<String, Object>> getTransactionsAfterTime(long afterTime) {
|
||||
List<Map<String, Object>> transactions = new ArrayList<>();
|
||||
try {
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"SELECT id, player_name, type, amount, target_player, operator, reason, balance_before, balance_after, time "
|
||||
+ "FROM bond_transaction WHERE time > ? ORDER BY time ASC");
|
||||
ps.setLong(1, afterTime);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
while (rs.next()) {
|
||||
Map<String, Object> tx = new LinkedHashMap<>();
|
||||
tx.put("id", rs.getInt("id"));
|
||||
tx.put("player_name", rs.getString("player_name"));
|
||||
tx.put("type", rs.getString("type"));
|
||||
tx.put("amount", rs.getInt("amount"));
|
||||
tx.put("target_player", rs.getString("target_player"));
|
||||
tx.put("operator", rs.getString("operator"));
|
||||
tx.put("reason", rs.getString("reason"));
|
||||
tx.put("balance_before", rs.getInt("balance_before"));
|
||||
tx.put("balance_after", rs.getInt("balance_after"));
|
||||
tx.put("time", rs.getLong("time"));
|
||||
transactions.add(tx);
|
||||
}
|
||||
rs.close();
|
||||
ps.close();
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return transactions;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* ★ 周期对账:水位线之后「还没推给 PHP」的笔数(只 COUNT,不拉行)。
|
||||
* 用来区分 PHP 的缺口是「正常的待推增量」还是「运行期被清空的历史」。
|
||||
*
|
||||
* @return 待推笔数;本地查不出来时返回 -1,调用方跳过本轮判定
|
||||
*/
|
||||
public long countTransactionsAfterTime(long afterTime) {
|
||||
try {
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"SELECT COUNT(*) FROM bond_transaction WHERE time > ?");
|
||||
ps.setLong(1, afterTime);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
long n = rs.next() ? rs.getLong(1) : 0L;
|
||||
rs.close();
|
||||
ps.close();
|
||||
return n;
|
||||
} catch (SQLException e) {
|
||||
return -1L;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* ★ 阶段E 多退少补:本机全部流水序列号(开服与 PHP 双向点验用)
|
||||
*/
|
||||
public List<Long> getAllTxIds() {
|
||||
List<Long> ids = new ArrayList<>();
|
||||
try {
|
||||
Statement st = db.createStatement();
|
||||
ResultSet rs = st.executeQuery(
|
||||
"SELECT id FROM bond_transaction ORDER BY id ASC");
|
||||
while (rs.next()) ids.add(rs.getLong(1));
|
||||
rs.close();
|
||||
st.close();
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* ★ 阶段E 多退少补:把「PHP 有、本机没有」的流水收回本地。
|
||||
* 只补流水记录(INSERT OR IGNORE 指定主键):
|
||||
* 不改余额、不触发交易事件、不发货,纯对账补记录。
|
||||
* PHP 的 tx_time 是秒级、本地 time 是毫秒 → 超过 1e12 视为毫秒,否则乘 1000。
|
||||
*
|
||||
* @param rows PHP pull_tx 返回的交易数组
|
||||
* @return 实际新写入的笔数
|
||||
*/
|
||||
public int importTransactionsFromWeb(List<Map<String, Object>> rows) {
|
||||
if (rows == null || rows.isEmpty()) return 0;
|
||||
int imported = 0;
|
||||
try {
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"INSERT OR IGNORE INTO bond_transaction "
|
||||
+ "(id, player_name, type, amount,"
|
||||
+ " target_player, operator,"
|
||||
+ " reason, balance_before,"
|
||||
+ " balance_after, time)"
|
||||
+ " VALUES (?,?,?,?,?,?,?,?,?,?)");
|
||||
for (Map<String, Object> row : rows) {
|
||||
Object idv = row.get("id");
|
||||
if (!(idv instanceof Number)) continue;
|
||||
long txId = ((Number) idv).longValue();
|
||||
if (txId <= 0) continue;
|
||||
|
||||
long time = 0L;
|
||||
Object tv = row.get("time");
|
||||
if (tv instanceof Number) {
|
||||
time = ((Number) tv).longValue();
|
||||
if (time > 0 && time < 1000000000000L) time *= 1000L;
|
||||
}
|
||||
|
||||
ps.setLong(1, txId);
|
||||
ps.setString(2, row.get("player_name") == null ? ""
|
||||
: String.valueOf(row.get("player_name")));
|
||||
ps.setString(3, row.get("type") == null ? ""
|
||||
: String.valueOf(row.get("type")));
|
||||
ps.setInt(4, row.get("amount") instanceof Number
|
||||
? ((Number) row.get("amount")).intValue() : 0);
|
||||
ps.setString(5, row.get("target_player") == null ? ""
|
||||
: String.valueOf(row.get("target_player")));
|
||||
ps.setString(6, row.get("operator") == null ? ""
|
||||
: String.valueOf(row.get("operator")));
|
||||
ps.setString(7, row.get("reason") == null ? ""
|
||||
: String.valueOf(row.get("reason")));
|
||||
ps.setInt(8, row.get("balance_before") instanceof Number
|
||||
? ((Number) row.get("balance_before")).intValue() : 0);
|
||||
ps.setInt(9, row.get("balance_after") instanceof Number
|
||||
? ((Number) row.get("balance_after")).intValue() : 0);
|
||||
ps.setLong(10, time);
|
||||
imported += ps.executeUpdate();
|
||||
}
|
||||
ps.close();
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,7 @@ import java.io.OutputStreamWriter;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.*;
|
||||
|
||||
public class BondPrinter {
|
||||
|
||||
@@ -38,9 +33,13 @@ public class BondPrinter {
|
||||
TYPE_NAMES.put("transfer_in", "转入");
|
||||
TYPE_NAMES.put("redeem", "CDK兑换");
|
||||
TYPE_NAMES.put("shop_buy", "商城购买");
|
||||
TYPE_NAMES.put("shop_sell", "商城出售"); // ← 补上
|
||||
TYPE_NAMES.put("daily_sign", "签到奖励");
|
||||
TYPE_NAMES.put("freeze", "冻结");
|
||||
TYPE_NAMES.put("unfreeze", "解冻");
|
||||
TYPE_NAMES.put("ticket_reward", "工单奖励");
|
||||
TYPE_NAMES.put("ticket_service", "工单服务奖励");
|
||||
|
||||
}
|
||||
|
||||
public BondPrinter(Main plugin) {
|
||||
@@ -151,8 +150,7 @@ public class BondPrinter {
|
||||
.getAllPlayerNames();
|
||||
if (players.isEmpty()) {
|
||||
if (sender != null) {
|
||||
sender.sendMessage(
|
||||
"§c没有流水数据");
|
||||
sender.sendMessage("§c没有流水数据");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -162,59 +160,84 @@ public class BondPrinter {
|
||||
+ ".xls";
|
||||
File file = new File(outputDir, fileName);
|
||||
|
||||
// ===== 收集所有流水 =====
|
||||
// key = 时间戳+玩家+金额,用于去重
|
||||
// value = 一行数据
|
||||
List<String[]> allRows = new ArrayList<>();
|
||||
Set<String> seenKeys = new HashSet<>();
|
||||
|
||||
for (String player : players) {
|
||||
List<Map<String, Object>> txs =
|
||||
plugin.getBonds()
|
||||
.getAllTransactions(player);
|
||||
for (Map<String, Object> tx : txs) {
|
||||
int txId = ((Number) tx.get("id"))
|
||||
.intValue();
|
||||
String dedupKey =
|
||||
String.valueOf(txId);
|
||||
if (!seenKeys.add(dedupKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String timeStr = txTime(tx);
|
||||
long timeMs = 0;
|
||||
Object t = tx.get("time");
|
||||
if (t != null) {
|
||||
timeMs =
|
||||
((Number) t).longValue();
|
||||
}
|
||||
|
||||
allRows.add(new String[]{
|
||||
player,
|
||||
timeStr,
|
||||
txTypeName(tx),
|
||||
String.valueOf(
|
||||
txInt(tx, "amount")),
|
||||
txStr(tx, "target_player"),
|
||||
txStr(tx, "operator"),
|
||||
txStr(tx, "reason"),
|
||||
String.valueOf(
|
||||
txInt(tx,
|
||||
"balance_before")),
|
||||
String.valueOf(
|
||||
txInt(tx,
|
||||
"balance_after")),
|
||||
// 额外存时间戳用于排序
|
||||
String.valueOf(timeMs)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 按时间戳排序 =====
|
||||
allRows.sort((a, b) -> {
|
||||
long ta = Long.parseLong(
|
||||
a[a.length - 1]);
|
||||
long tb = Long.parseLong(
|
||||
b[b.length - 1]);
|
||||
return Long.compare(ta, tb);
|
||||
});
|
||||
|
||||
// ===== 写入文件 =====
|
||||
PrintWriter pw = new PrintWriter(
|
||||
new OutputStreamWriter(
|
||||
new FileOutputStream(file),
|
||||
StandardCharsets.UTF_8));
|
||||
try {
|
||||
writeXmlHeader(pw, "全服流水");
|
||||
writeXmlRow(pw,
|
||||
new String[]{
|
||||
"玩家", "时间",
|
||||
"类型", "金额",
|
||||
"对象", "操作者",
|
||||
"理由",
|
||||
"操作前余额",
|
||||
"操作后余额"});
|
||||
writeXmlRow(pw, new String[]{
|
||||
"玩家", "时间", "类型",
|
||||
"金额", "对象", "操作者",
|
||||
"理由", "操作前余额",
|
||||
"操作后余额"
|
||||
});
|
||||
|
||||
Set<String> written = new HashSet<>();
|
||||
int totalRows = 0;
|
||||
|
||||
for (String player : players) {
|
||||
List<Map<String, Object>> txs =
|
||||
plugin.getBonds()
|
||||
.getAllTransactions(
|
||||
player);
|
||||
for (Map<String, Object> tx : txs) {
|
||||
int txId = ((Number) tx
|
||||
.get("id"))
|
||||
.intValue();
|
||||
if (!written.add(
|
||||
String.valueOf(
|
||||
txId))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
writeXmlRow(pw, new String[]{
|
||||
player,
|
||||
txTime(tx),
|
||||
txTypeName(tx),
|
||||
String.valueOf(
|
||||
txInt(tx,
|
||||
"amount")),
|
||||
txStr(tx,
|
||||
"target_player"),
|
||||
txStr(tx, "operator"),
|
||||
txStr(tx, "reason"),
|
||||
String.valueOf(
|
||||
txInt(tx,
|
||||
"balance_before")),
|
||||
String.valueOf(
|
||||
txInt(tx,
|
||||
"balance_after"))
|
||||
});
|
||||
totalRows++;
|
||||
}
|
||||
for (String[] row : allRows) {
|
||||
// 去掉最后的时间戳列
|
||||
String[] cells =
|
||||
new String[row.length - 1];
|
||||
System.arraycopy(row, 0,
|
||||
cells, 0, cells.length);
|
||||
writeXmlRow(pw, cells);
|
||||
}
|
||||
|
||||
writeXmlFooter(pw);
|
||||
@@ -222,14 +245,15 @@ public class BondPrinter {
|
||||
|
||||
if (sender != null) {
|
||||
sender.sendMessage(
|
||||
"§a[打印] §f全服流水"
|
||||
+ " §a已导出");
|
||||
"§a[打印] §f全服流水 §a已导出");
|
||||
sender.sendMessage("§7文件: §f"
|
||||
+ fileName);
|
||||
sender.sendMessage("§7共 §f"
|
||||
+ players.size()
|
||||
+ " 名玩家,§f"
|
||||
+ totalRows + " §7条");
|
||||
+ allRows.size() + " §7条");
|
||||
sender.sendMessage(
|
||||
"§7按时间从早到晚排序");
|
||||
sender.sendMessage(
|
||||
"§724小时后自动删除");
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ package Sdf1_login;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.scoreboard.Scoreboard;
|
||||
|
||||
import java.io.*;
|
||||
import java.sql.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class CDKManager {
|
||||
|
||||
private final Main plugin;
|
||||
@@ -25,7 +25,9 @@ public class CDKManager {
|
||||
try {
|
||||
Class.forName("org.sqlite.JDBC");
|
||||
File dbFile = new File(
|
||||
plugin.getDataFolder(), "bond.db");
|
||||
new File(plugin.getDataFolder(), "财务债券"),
|
||||
"bond.db");
|
||||
dbFile.getParentFile().mkdirs();
|
||||
db = DriverManager.getConnection(
|
||||
"jdbc:sqlite:" + dbFile.getAbsolutePath());
|
||||
Statement st = db.createStatement();
|
||||
@@ -41,198 +43,18 @@ public class CDKManager {
|
||||
st.close();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(
|
||||
"[CDK] DB init failed: " + e.getMessage(), e);
|
||||
"[CDK] DB init failed: "
|
||||
+ e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public void loadCDKsFromDir() {
|
||||
File dir = new File(
|
||||
plugin.getDataFolder(), "cdk");
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs();
|
||||
plugin.getLogger().info(
|
||||
"[CDK] 已创建 cdk/ 目录");
|
||||
return;
|
||||
}
|
||||
File[] files = dir.listFiles(
|
||||
(d, n) -> n.endsWith(".txt"));
|
||||
if (files == null || files.length == 0) {
|
||||
plugin.getLogger().warning(
|
||||
"[CDK] cdk/ 目录下"
|
||||
+ "无 .txt 文件");
|
||||
return;
|
||||
}
|
||||
int count = 0;
|
||||
for (File f : files) {
|
||||
int loaded = loadCDKFile(f);
|
||||
plugin.getLogger().info(
|
||||
"[CDK] 文件: " + f.getName()
|
||||
+ " 加载 " + loaded + " 条");
|
||||
count += loaded;
|
||||
}
|
||||
plugin.getLogger().info(
|
||||
"[CDK] 共导入 " + count + " 条");
|
||||
}
|
||||
// ===== CDK兑换(供SDF1调用) =====
|
||||
|
||||
/** 单文件加载,返回成功数 */
|
||||
private int loadCDKFile(File f) {
|
||||
int count = 0;
|
||||
try {
|
||||
BufferedReader br = new BufferedReader(
|
||||
new InputStreamReader(
|
||||
new FileInputStream(f),
|
||||
StandardCharsets.UTF_8));
|
||||
String line;
|
||||
int lineNum = 0;
|
||||
while ((line = br.readLine()) != null) {
|
||||
lineNum++;
|
||||
line = line.trim();
|
||||
if (line.isEmpty()) continue;
|
||||
if (line.startsWith("#")) continue;
|
||||
if (line.startsWith("//")) continue;
|
||||
|
||||
// 支持格式:
|
||||
// CODE 100
|
||||
// CODE,100
|
||||
// CODE 100 bond
|
||||
// CODE,100,bond
|
||||
String[] parts = line
|
||||
.split("[,\\s]+");
|
||||
if (parts.length < 2) {
|
||||
plugin.getLogger().warning(
|
||||
"[CDK] " + f.getName()
|
||||
+ " L" + lineNum
|
||||
+ " 格式错误: "
|
||||
+ line);
|
||||
continue;
|
||||
}
|
||||
|
||||
String code = parts[0].trim();
|
||||
if (code.isEmpty()) continue;
|
||||
|
||||
int amount;
|
||||
try {
|
||||
amount = Integer.parseInt(
|
||||
parts[1].trim());
|
||||
} catch (NumberFormatException e) {
|
||||
plugin.getLogger().warning(
|
||||
"[CDK] " + f.getName()
|
||||
+ " L" + lineNum
|
||||
+ " 金额格式错误: "
|
||||
+ parts[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
String type = parts.length >= 3
|
||||
? parts[2].trim() : "bond";
|
||||
|
||||
boolean ok = addCDK(code, amount,
|
||||
type);
|
||||
if (ok) {
|
||||
count++;
|
||||
} else {
|
||||
plugin.getLogger().warning(
|
||||
"[CDK] " + f.getName()
|
||||
+ " L" + lineNum
|
||||
+ " 重复跳过: "
|
||||
+ code);
|
||||
}
|
||||
}
|
||||
br.close();
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning(
|
||||
"[CDK] 读取失败: " + f.getName()
|
||||
+ " " + e.getMessage());
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
public int importFromFile(String fileName) {
|
||||
File dir = new File(
|
||||
plugin.getDataFolder(), "cdk");
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs();
|
||||
}
|
||||
|
||||
// 先在 cdk/ 目录下找
|
||||
File f = new File(dir, fileName);
|
||||
if (!f.exists()) {
|
||||
// 再在插件根目录找
|
||||
f = new File(
|
||||
plugin.getDataFolder(), fileName);
|
||||
}
|
||||
if (!f.exists()) {
|
||||
plugin.getLogger().warning(
|
||||
"[CDK] 文件不存在: "
|
||||
+ fileName);
|
||||
return -1;
|
||||
}
|
||||
|
||||
plugin.getLogger().info(
|
||||
"[CDK] 开始导入: "
|
||||
+ f.getAbsolutePath()
|
||||
+ " 大小:" + f.length()
|
||||
+ " bytes");
|
||||
|
||||
int count = loadCDKFile(f);
|
||||
plugin.getLogger().info(
|
||||
"[CDK] 导入完成: " + count + " 条");
|
||||
return count;
|
||||
}
|
||||
|
||||
public int importFromSdf1(String fileName) {
|
||||
int count = 0;
|
||||
Scoreboard board = Bukkit.getScoreboardManager()
|
||||
.getMainScoreboard();
|
||||
// 尝试从sdf1的计分板读取口令
|
||||
// 这部分依赖sdf1插件的配置
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
private boolean addCDK(String code, int amount,
|
||||
String type) {
|
||||
try {
|
||||
// 先检查是否已存在
|
||||
PreparedStatement check =
|
||||
db.prepareStatement(
|
||||
"SELECT code FROM cdk "
|
||||
+ "WHERE code=?");
|
||||
check.setString(1, code);
|
||||
ResultSet rs = check.executeQuery();
|
||||
if (rs.next()) {
|
||||
rs.close();
|
||||
check.close();
|
||||
return false;
|
||||
}
|
||||
rs.close();
|
||||
check.close();
|
||||
|
||||
PreparedStatement ps =
|
||||
db.prepareStatement(
|
||||
"INSERT INTO cdk "
|
||||
+ "(code, amount, type,"
|
||||
+ " created_time)"
|
||||
+ " VALUES (?,?,?,?)");
|
||||
ps.setString(1, code);
|
||||
ps.setInt(2, amount);
|
||||
ps.setString(3,
|
||||
type != null ? type : "bond");
|
||||
ps.setLong(4,
|
||||
System.currentTimeMillis());
|
||||
ps.executeUpdate();
|
||||
ps.close();
|
||||
return true;
|
||||
} catch (SQLException e) {
|
||||
plugin.getLogger().warning(
|
||||
"[CDK] addCDK失败: " + code
|
||||
+ " " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 替换整个 redeem 方法 =====
|
||||
/**
|
||||
* 兑换CDK,返回结果字符串
|
||||
* @return "success:金额:余额前:余额后"
|
||||
* 或 "fail:原因"
|
||||
*/
|
||||
public String redeem(String code, String player) {
|
||||
try {
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
@@ -242,45 +64,54 @@ public class CDKManager {
|
||||
ResultSet rs = ps.executeQuery();
|
||||
if (!rs.next()) {
|
||||
rs.close(); ps.close();
|
||||
return null;
|
||||
return "fail:not_found";
|
||||
}
|
||||
int amount = rs.getInt("amount");
|
||||
String type = rs.getString("type");
|
||||
int used = rs.getInt("used");
|
||||
rs.close(); ps.close();
|
||||
|
||||
if (used == 1) return "该兑换码已被使用";
|
||||
if (used == 1)
|
||||
return "fail:already_used";
|
||||
|
||||
// [新增] 冻结检查
|
||||
if (plugin.getBonds().isFrozen(player)) {
|
||||
return "账户已被冻结,无法兑换";
|
||||
}
|
||||
if (plugin.getBonds()
|
||||
.isFrozen(player))
|
||||
return "fail:frozen";
|
||||
|
||||
PreparedStatement up = db.prepareStatement(
|
||||
"UPDATE cdk SET used=1,"
|
||||
+ "used_by=?,used_time=? "
|
||||
+ "WHERE code=?");
|
||||
up.setString(1, player);
|
||||
up.setLong(2, System.currentTimeMillis());
|
||||
up.setLong(2,
|
||||
System.currentTimeMillis());
|
||||
up.setString(3, code);
|
||||
up.executeUpdate(); up.close();
|
||||
|
||||
if ("bond".equals(type)) {
|
||||
// [修改] 使用带流水的重载方法
|
||||
plugin.getBonds().addBonds(player, amount,
|
||||
BondManager.TX_REDEEM, "",
|
||||
"system", "CDK兑换: " + code);
|
||||
return "兑换成功!获得 " + amount + " 债券";
|
||||
int bef = plugin.getBonds()
|
||||
.getBonds(player);
|
||||
plugin.getBonds().addBonds(player,
|
||||
amount,
|
||||
BondManager.TX_REDEEM,
|
||||
"", "CDK系统",
|
||||
"CDK兑换: " + code);
|
||||
int aft = plugin.getBonds()
|
||||
.getBonds(player);
|
||||
return "success:" + amount
|
||||
+ ":" + bef + ":" + aft;
|
||||
}
|
||||
return null;
|
||||
return "fail:unknown_type";
|
||||
} catch (SQLException e) {
|
||||
return "兑换失败";
|
||||
return "fail:" + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 聊天输入系统(主插件转发调用)=====
|
||||
// ===== 聊天输入系统 =====
|
||||
|
||||
public void requestInput(Player p, String type, String context) {
|
||||
public void requestInput(Player p,
|
||||
String type,
|
||||
String context) {
|
||||
CDKInput input = new CDKInput();
|
||||
input.type = type;
|
||||
input.context = context;
|
||||
@@ -289,129 +120,104 @@ public class CDKManager {
|
||||
}
|
||||
|
||||
public boolean isListening(String playerName) {
|
||||
return pendingInputs.containsKey(playerName);
|
||||
return pendingInputs
|
||||
.containsKey(playerName);
|
||||
}
|
||||
|
||||
public void onChat(Player p, String msg) {
|
||||
CDKInput input = pendingInputs.remove(p.getName());
|
||||
CDKInput input = pendingInputs
|
||||
.remove(p.getName());
|
||||
if (input == null) return;
|
||||
|
||||
if (System.currentTimeMillis() - input.time > 60000) {
|
||||
p.sendMessage("§c输入超时,请重新操作");
|
||||
if (System.currentTimeMillis()
|
||||
- input.time > 60000) {
|
||||
p.sendMessage("§c输入超时");
|
||||
return;
|
||||
}
|
||||
|
||||
if ("cdk".equals(input.type)) {
|
||||
String result = redeem(msg.trim(), p.getName());
|
||||
p.sendMessage(result != null
|
||||
? "§e§l[CDK] §f" + result
|
||||
: "§c§l[CDK] §f无效的兑换码");
|
||||
String result = redeem(
|
||||
msg.trim(), p.getName());
|
||||
if (result != null
|
||||
&& result.startsWith("success:")) {
|
||||
String[] parts = result.split(":");
|
||||
int amt = Integer.parseInt(parts[1]);
|
||||
int bef = Integer.parseInt(parts[2]);
|
||||
int aft = Integer.parseInt(parts[3]);
|
||||
p.sendMessage("§e§l[CDK] §f兑换成功!"
|
||||
+ "获得 §c" + amt + "§f债券");
|
||||
p.sendMessage("§7余额: §e" + bef
|
||||
+ " §7-> §a" + aft);
|
||||
} else {
|
||||
p.sendMessage("§c§l[CDK] §f"
|
||||
+ (result != null
|
||||
? result.replace("fail:", "")
|
||||
: "无效的兑换码"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ("econ".equals(input.type)) {
|
||||
handleEconInput(p, msg.trim(), input.context);
|
||||
handleEconInput(p, msg.trim(),
|
||||
input.context);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("bond".equals(input.type)) {
|
||||
handleBondInput(p, msg.trim(), input.context);
|
||||
handleBondInput(p, msg.trim(),
|
||||
input.context);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("points".equals(input.type)) {
|
||||
handlePointsInput(p, msg.trim(),
|
||||
input.context);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("gift_target".equals(input.type)) {
|
||||
// 赠予用户组:输入目标玩家名
|
||||
String targetName = msg.trim();
|
||||
if (!targetName.isEmpty()) {
|
||||
GUIManager guiMgr = plugin.getGui();
|
||||
if (guiMgr != null) {
|
||||
guiMgr.handleGiftTargetInput(p, targetName);
|
||||
}
|
||||
} else {
|
||||
p.sendMessage("§c请输入有效的玩家名");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void handleEconInput(Player admin, String msg, String target) {
|
||||
net.milkbowl.vault.economy.Economy econ = null;
|
||||
try {
|
||||
var reg = plugin.getServer().getServicesManager()
|
||||
.getRegistration(net.milkbowl.vault.economy.Economy.class);
|
||||
if (reg != null) econ = reg.getProvider();
|
||||
} catch (Exception ignored) {}
|
||||
if (econ == null) {
|
||||
admin.sendMessage("§c经济系统不可用");
|
||||
return;
|
||||
}
|
||||
if (!msg.startsWith("+") && !msg.startsWith("-")) {
|
||||
admin.sendMessage("§c格式: §a+100 §7给钱 / §c-50 §7扣钱");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String numStr = msg.substring(1).trim();
|
||||
double amount = Double.parseDouble(numStr);
|
||||
if (amount <= 0) {
|
||||
admin.sendMessage("§c金额必须大于0");
|
||||
return;
|
||||
}
|
||||
if (msg.startsWith("-")) {
|
||||
Player tp = plugin.getServer().getPlayerExact(target);
|
||||
if (tp == null) {
|
||||
admin.sendMessage("§c" + target + " 不在线");
|
||||
return;
|
||||
}
|
||||
if (!econ.has(tp, amount)) {
|
||||
admin.sendMessage("§c" + target + " 余额不足");
|
||||
return;
|
||||
}
|
||||
double oldBal = econ.getBalance(tp);
|
||||
econ.withdrawPlayer(tp, amount);
|
||||
double newBal = econ.getBalance(tp);
|
||||
admin.sendMessage("§7============== §c余额扣款 §7==============");
|
||||
admin.sendMessage("§7目标: §e" + target);
|
||||
admin.sendMessage("§c扣款: §e-$" + String.format("%.2f", amount));
|
||||
admin.sendMessage("§7余额: §e" + String.format("%.2f", oldBal) + " → §c" + String.format("%.2f", newBal));
|
||||
admin.sendMessage("§7============== §c余额扣款 §7==============");
|
||||
tp.sendMessage("§7============== §c余额扣款 §7==============");
|
||||
tp.sendMessage("§c扣款: §e-$" + String.format("%.2f", amount));
|
||||
tp.sendMessage("§7余额: §e" + String.format("%.2f", oldBal) + " → §c" + String.format("%.2f", newBal));
|
||||
tp.sendMessage("§7操作者: §e" + admin.getName());
|
||||
tp.sendMessage("§7============== §c余额扣款 §7==============");
|
||||
} else {
|
||||
double oldBal = econ.getBalance(target);
|
||||
econ.depositPlayer(target, amount);
|
||||
double newBal = econ.getBalance(target);
|
||||
Player tp = plugin.getServer().getPlayerExact(target);
|
||||
admin.sendMessage("§7============== §6余额转账 §7==============");
|
||||
admin.sendMessage("§7目标: §e" + target);
|
||||
admin.sendMessage("§a到账: §e$" + String.format("%.2f", amount));
|
||||
admin.sendMessage("§7余额: §e" + String.format("%.2f", oldBal) + " → §a" + String.format("%.2f", newBal));
|
||||
admin.sendMessage("§7============== §6余额转账 §7==============");
|
||||
if (tp != null) {
|
||||
tp.sendMessage("§7============== §a收到转账 §7==============");
|
||||
tp.sendMessage("§a到账: §e$" + String.format("%.2f", amount));
|
||||
tp.sendMessage("§7余额: §e" + String.format("%.2f", oldBal) + " → §a" + String.format("%.2f", newBal));
|
||||
tp.sendMessage("§7来自: §e" + admin.getName());
|
||||
tp.sendMessage("§7============== §a收到转账 §7==============");
|
||||
}
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
admin.sendMessage("§c格式错误,请输入 §a+数字 §7或 §c-数字");
|
||||
}
|
||||
private void handleEconInput(Player admin,
|
||||
String msg,
|
||||
String target) {
|
||||
// Economy已移除,所有经济操作改用债券系统
|
||||
admin.sendMessage("§c[经济操作] 功能已移除,请使用债券系统");
|
||||
}
|
||||
// ===== 替换整个 handleBondInput 方法 =====
|
||||
|
||||
private void handleBondInput(Player admin,
|
||||
String msg,
|
||||
String target) {
|
||||
if (!msg.startsWith("+")
|
||||
&& !msg.startsWith("-")) {
|
||||
admin.sendMessage(
|
||||
"§c格式: §a+100 §7给债券"
|
||||
+ " / §c-50 §7扣债券");
|
||||
admin.sendMessage(
|
||||
"§7可附加理由: §a+100 活动奖励");
|
||||
admin.sendMessage("§c格式: §a+100"
|
||||
+ " §7或 §c-50");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String sign = msg.substring(0, 1);
|
||||
String rest = msg.substring(1).trim();
|
||||
|
||||
// 拆分金额和理由
|
||||
String[] parts = rest.split("\\s+", 2);
|
||||
String rest =
|
||||
msg.substring(1).trim();
|
||||
String[] parts =
|
||||
rest.split("\\s+", 2);
|
||||
int amount = Integer.parseInt(parts[0]);
|
||||
String reason = parts.length >= 2
|
||||
? parts[1].trim()
|
||||
: "管理员手动调整";
|
||||
|
||||
if (amount <= 0) {
|
||||
admin.sendMessage("§c金额必须大于0");
|
||||
admin.sendMessage("§c金额必须>0");
|
||||
return;
|
||||
}
|
||||
String opName = admin.getName();
|
||||
@@ -422,122 +228,66 @@ public class CDKManager {
|
||||
if ("-".equals(sign)) {
|
||||
if (plugin.getBonds()
|
||||
.isFrozen(target)) {
|
||||
admin.sendMessage("§c" + target
|
||||
+ " 账户已被冻结");
|
||||
admin.sendMessage("§c账户冻结");
|
||||
return;
|
||||
}
|
||||
int oldBal = plugin.getBonds()
|
||||
.getBonds(target);
|
||||
if (plugin.getBonds().deductBonds(
|
||||
target, amount, txType,
|
||||
"", opName, reason)) {
|
||||
int newBal = plugin.getBonds()
|
||||
.getBonds(target);
|
||||
admin.sendMessage(
|
||||
"§7============== §c"
|
||||
+ "债券扣款 §7"
|
||||
+ "=============");
|
||||
admin.sendMessage(
|
||||
"§7目标: §e" + target);
|
||||
admin.sendMessage("§c扣款: §c-"
|
||||
+ amount + " §6枚");
|
||||
admin.sendMessage("§7理由: §f"
|
||||
+ reason);
|
||||
admin.sendMessage("§7债券: §e"
|
||||
+ oldBal + " → §c"
|
||||
+ newBal);
|
||||
admin.sendMessage(
|
||||
"§7============== §c"
|
||||
+ "债券扣款 §7"
|
||||
+ "=============");
|
||||
Player tp = plugin.getServer()
|
||||
.getPlayerExact(target);
|
||||
if (tp != null) {
|
||||
tp.sendMessage(
|
||||
"§7============== §c"
|
||||
+ "债券扣款 §7"
|
||||
+ "=============");
|
||||
tp.sendMessage("§c扣款: §c-"
|
||||
+ amount + " §6枚");
|
||||
tp.sendMessage("§7理由: §f"
|
||||
+ reason);
|
||||
tp.sendMessage("§7债券: §e"
|
||||
+ oldBal + " → §c"
|
||||
+ newBal);
|
||||
tp.sendMessage("§7操作者: §e"
|
||||
+ opName);
|
||||
tp.sendMessage(
|
||||
"§7============== §c"
|
||||
+ "债券扣款 §7"
|
||||
+ "=============");
|
||||
}
|
||||
admin.sendMessage("§a扣除成功");
|
||||
} else {
|
||||
admin.sendMessage("§c" + target
|
||||
+ " 债券不足(当前: "
|
||||
+ oldBal + ")");
|
||||
admin.sendMessage("§c债券不足");
|
||||
}
|
||||
} else {
|
||||
if (plugin.getBonds()
|
||||
.isFrozen(target)) {
|
||||
admin.sendMessage("§c" + target
|
||||
+ " 账户已被冻结");
|
||||
admin.sendMessage("§c账户冻结");
|
||||
return;
|
||||
}
|
||||
int oldBal = plugin.getBonds()
|
||||
.getBonds(target);
|
||||
plugin.getBonds().addBonds(target,
|
||||
amount, txType,
|
||||
"", opName, reason);
|
||||
int newBal = plugin.getBonds()
|
||||
.getBonds(target);
|
||||
admin.sendMessage(
|
||||
"§7============== §6"
|
||||
+ "债券转账 §7"
|
||||
+ "=============");
|
||||
admin.sendMessage(
|
||||
"§7目标: §e" + target);
|
||||
admin.sendMessage("§a到账: §a+"
|
||||
+ amount + " §6枚");
|
||||
admin.sendMessage("§7理由: §f"
|
||||
+ reason);
|
||||
admin.sendMessage("§7债券: §e"
|
||||
+ oldBal + " → §a"
|
||||
+ newBal);
|
||||
admin.sendMessage(
|
||||
"§7============== §6"
|
||||
+ "债券转账 §7"
|
||||
+ "=============");
|
||||
Player tp = plugin.getServer()
|
||||
.getPlayerExact(target);
|
||||
if (tp != null) {
|
||||
tp.sendMessage(
|
||||
"§7============== §a"
|
||||
+ "收到债券 §7"
|
||||
+ "=============");
|
||||
tp.sendMessage("§a到账: §a+"
|
||||
+ amount + " §6枚");
|
||||
tp.sendMessage("§7理由: §f"
|
||||
+ reason);
|
||||
tp.sendMessage("§7债券: §e"
|
||||
+ oldBal + " → §a"
|
||||
+ newBal);
|
||||
tp.sendMessage("§7来自: §e"
|
||||
+ opName);
|
||||
tp.sendMessage(
|
||||
"§7============== §a"
|
||||
+ "收到债券 §7"
|
||||
+ "=============");
|
||||
}
|
||||
admin.sendMessage("§a发放成功");
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
admin.sendMessage("§c格式错误,请输入 "
|
||||
+ "§a+数字 §7或 §c-数字");
|
||||
admin.sendMessage("§c格式错误");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private void handlePointsInput(Player admin,
|
||||
String msg,
|
||||
String target) {
|
||||
if (!msg.startsWith("+")
|
||||
&& !msg.startsWith("-")) {
|
||||
admin.sendMessage("§c格式: §a+100"
|
||||
+ " §7或 §c-50");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String sign = msg.substring(0, 1);
|
||||
String rest =
|
||||
msg.substring(1).trim();
|
||||
int amount = Integer.parseInt(rest);
|
||||
if (amount <= 0) {
|
||||
admin.sendMessage("§c金额必须>0");
|
||||
return;
|
||||
}
|
||||
if ("-".equals(sign)) {
|
||||
if (plugin.getDb().deductPoints(
|
||||
target, amount)) {
|
||||
admin.sendMessage("§a扣除成功");
|
||||
} else {
|
||||
admin.sendMessage("§c积分不足");
|
||||
}
|
||||
} else {
|
||||
plugin.getDb().addPoints(
|
||||
target, amount);
|
||||
admin.sendMessage("§a发放成功");
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
admin.sendMessage("§c格式错误");
|
||||
}
|
||||
}
|
||||
|
||||
public void cancelInput(String player) {
|
||||
pendingInputs.remove(player);
|
||||
|
||||
@@ -38,7 +38,9 @@ public class ChatInputManager {
|
||||
TICKET_ADMIN_DESC,
|
||||
TICKET_ADMIN_PROVIDER,
|
||||
BACK_CHECK_ECONOMY_CONFIRM,
|
||||
}
|
||||
USER_MGMT_SEARCH,
|
||||
|
||||
}
|
||||
|
||||
public static class PlayerState {
|
||||
public InputType type = InputType.NONE;
|
||||
@@ -49,6 +51,7 @@ public class ChatInputManager {
|
||||
public int ticketId = 0;
|
||||
public int smtpStep = 0;
|
||||
public int scoreValue = 0;
|
||||
|
||||
}
|
||||
|
||||
private final Map<String, PlayerState> states =
|
||||
@@ -87,9 +90,9 @@ public class ChatInputManager {
|
||||
name, "password_salt");
|
||||
String hash =
|
||||
PasswordUtils.hash(msg, salt);
|
||||
boolean mainOk =
|
||||
db.checkPassword(name, hash);
|
||||
if (mainOk) {
|
||||
// ★ 同时校验真实密码 + 临时密码(改密不受临时密码5分钟有效期限制)
|
||||
String result = db.checkPasswordForChange(name, hash);
|
||||
if ("main".equals(result)) {
|
||||
state.type =
|
||||
InputType.CHANGE_PWD_STEP2;
|
||||
state.ticketTitle = "main";
|
||||
@@ -97,10 +100,7 @@ public class ChatInputManager {
|
||||
"§e请输入新密码:");
|
||||
return;
|
||||
}
|
||||
boolean tempOk =
|
||||
db.checkPasswordOrTemp(
|
||||
name, hash);
|
||||
if (tempOk) {
|
||||
if ("temp".equals(result)) {
|
||||
state.type =
|
||||
InputType.CHANGE_PWD_STEP2;
|
||||
state.ticketTitle = "temp";
|
||||
@@ -144,11 +144,26 @@ public class ChatInputManager {
|
||||
"password_hash", newHash);
|
||||
db.setField(name,
|
||||
"password_salt", newSalt);
|
||||
// ★ 改密成功:作废旧的临时密码
|
||||
db.clearTempPassword(name);
|
||||
// ★ 同步新密码到Web端(异步,不阻塞主线程)
|
||||
final Main self = mainPlugin;
|
||||
Bukkit.getScheduler().runTaskAsynchronously(self, () -> {
|
||||
if (self.webManager != null) {
|
||||
self.webManager.pushWebLoginCredentials();
|
||||
}
|
||||
});
|
||||
mainPlugin
|
||||
.getNeedsPasswordChange()
|
||||
.remove(name);
|
||||
p.sendMessage(config.msg(
|
||||
"password_changed"));
|
||||
// ★ 改密成功:actionbar 提示1分钟保存账号密码
|
||||
mainPlugin.showCredentialSaveReminder(
|
||||
p, name, msg);
|
||||
if ("temp".equals(state.ticketTitle)) {
|
||||
p.sendMessage("§7(本次是用临时密码改的,临时密码已作废)");
|
||||
}
|
||||
reset(p);
|
||||
return;
|
||||
}
|
||||
@@ -161,10 +176,21 @@ public class ChatInputManager {
|
||||
reset(p);
|
||||
return;
|
||||
}
|
||||
String emailAddr = msg.trim();
|
||||
// 邮箱后缀黑白名单校验
|
||||
if (!config.isEmailSuffixAllowed(emailAddr)) {
|
||||
String mode = config.getEmailValidationMode();
|
||||
if ("白名单".equals(mode)) {
|
||||
p.sendMessage("§c该邮箱后缀不在允许列表中");
|
||||
} else if ("黑名单".equals(mode)) {
|
||||
p.sendMessage("§c该邮箱后缀被禁止使用");
|
||||
}
|
||||
reset(p);
|
||||
return;
|
||||
}
|
||||
String oldEmail = (String)
|
||||
db.getField(p.getName(),
|
||||
"email");
|
||||
String emailAddr = msg.trim();
|
||||
// 已绑定邮箱:验证登录密码
|
||||
if (oldEmail != null
|
||||
&& !oldEmail.isEmpty()) {
|
||||
@@ -351,14 +377,33 @@ public class ChatInputManager {
|
||||
case ADMIN_DELETE_CONFIRM: {
|
||||
if (msg.equals(
|
||||
state.targetPlayer)) {
|
||||
db.deleteUser(
|
||||
// ★ 精准匹配:TEST 与 test 是两个人,只有名字完全相等才删
|
||||
if (db.findExactUserName(state.targetPlayer) == null) {
|
||||
String twin = db.findSimilarUserNameIgnoreCase(
|
||||
state.targetPlayer);
|
||||
p.sendMessage("§c账号 " + state.targetPlayer
|
||||
+ " 不存在"
|
||||
+ (twin != null
|
||||
? "(仅存在大小写不同的账号 §e" + twin
|
||||
+ "§c,已按精准匹配跳过)" : "")
|
||||
+ ",未执行删除");
|
||||
reset(p);
|
||||
return;
|
||||
}
|
||||
int deleted = db.deleteUser(
|
||||
state.targetPlayer);
|
||||
p.sendMessage("§a已删除 "
|
||||
+ state.targetPlayer);
|
||||
Player tgt =
|
||||
Bukkit.getPlayer(
|
||||
state.targetPlayer);
|
||||
if (tgt != null
|
||||
if (plugin.webManager != null && deleted > 0) {
|
||||
plugin.webManager.deleteWebUser(state.targetPlayer);
|
||||
}
|
||||
p.sendMessage(deleted > 0
|
||||
? "§a已删除 " + state.targetPlayer
|
||||
: "§c删除失败:账号 "
|
||||
+ state.targetPlayer + " 不存在");
|
||||
// ★ getPlayerExact:只踢名字完全相同的在线玩家,
|
||||
// 避免把 TEST 误当成 test 踢下线
|
||||
Player tgt = Bukkit.getPlayerExact(
|
||||
state.targetPlayer);
|
||||
if (deleted > 0 && tgt != null
|
||||
&& tgt.isOnline())
|
||||
tgt.kickPlayer(
|
||||
"§c账号已被删除");
|
||||
|
||||
@@ -26,6 +26,7 @@ public class CheckInManager {
|
||||
return today.equals(last);
|
||||
}
|
||||
|
||||
// ===== 签到 =====
|
||||
// ===== 签到 =====
|
||||
public String checkIn(Player p) {
|
||||
String name = p.getName();
|
||||
@@ -53,53 +54,71 @@ public class CheckInManager {
|
||||
java.util.Date todayD =
|
||||
new SimpleDateFormat("yyyy-MM-dd")
|
||||
.parse(today);
|
||||
long diff = todayD.getTime() - lastD.getTime();
|
||||
long diff = todayD.getTime()
|
||||
- lastD.getTime();
|
||||
if (diff > 86400000L * 2) {
|
||||
streak = 0;
|
||||
// 间隔超过2天,重置连续天数
|
||||
streak = 1;
|
||||
} else if (diff > 86400000L) {
|
||||
// 间隔1-2天(连续签到),天数+1
|
||||
streak++;
|
||||
}
|
||||
// 间隔<=1天(同一天),不改变连续天数
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
} else {
|
||||
// 首次签到
|
||||
streak = 1;
|
||||
}
|
||||
|
||||
if (streak == 0) streak = 1;
|
||||
else streak++;
|
||||
|
||||
double multi = 1.0;
|
||||
if (streak >= 21) multi = 1.5;
|
||||
else if (streak >= 14) multi = 1.3;
|
||||
else if (streak >= 7) multi = 1.1;
|
||||
|
||||
int basePoints = plugin.getConfig2().checkinPoints;
|
||||
int basePoints =
|
||||
plugin.getConfig2().checkinPoints;
|
||||
int points = (int) (basePoints * multi);
|
||||
|
||||
plugin.getDb().addPoints(name, points);
|
||||
plugin.getDb().setField(name, "checkin_streak", streak);
|
||||
plugin.getDb().setField(name, "last_checkin_date", today);
|
||||
plugin.getDb().setField(name,
|
||||
"checkin_streak", streak);
|
||||
plugin.getDb().setField(name,
|
||||
"last_checkin_date", today);
|
||||
|
||||
Object totalObj = plugin.getDb()
|
||||
.getField(name, "total_checkin_days");
|
||||
int total = totalObj != null
|
||||
? ((Number) totalObj).intValue()
|
||||
: 0;
|
||||
plugin.getDb().setField(name, "total_checkin_days",
|
||||
total + 1);
|
||||
plugin.getDb().setField(name,
|
||||
"total_checkin_days", total + 1);
|
||||
|
||||
int bondReward = 1;
|
||||
if (streak >= 21) bondReward = 3;
|
||||
else if (streak >= 14) bondReward = 2;
|
||||
else if (streak >= 7) bondReward = 2;
|
||||
plugin.getBonds().addBonds(name, bondReward);
|
||||
// ★ 债券盲盒奖励(6参数版,写入流水)★
|
||||
int bondMin =
|
||||
plugin.getConfig2().checkinBondMin;
|
||||
int bondMax =
|
||||
plugin.getConfig2().checkinBondMax;
|
||||
if (bondMin <= 0) bondMin = 1;
|
||||
if (bondMax < bondMin) bondMax = bondMin;
|
||||
int bondReward = bondMin
|
||||
+ new java.util.Random()
|
||||
.nextInt(bondMax - bondMin + 1);
|
||||
plugin.getBonds().addBonds(name, bondReward,
|
||||
"daily_sign", "", "签到系统",
|
||||
"每日签到连续" + streak + "天");
|
||||
|
||||
return plugin.getConfig2().msg("checkin_success")
|
||||
.replace("{points}", String.valueOf(points))
|
||||
.replace("{streak}", String.valueOf(streak))
|
||||
.replace("{multi}", String.valueOf(multi))
|
||||
return plugin.getConfig2()
|
||||
.msg("checkin_success")
|
||||
.replace("{points}",
|
||||
String.valueOf(points))
|
||||
.replace("{streak}",
|
||||
String.valueOf(streak))
|
||||
.replace("{multi}",
|
||||
String.valueOf(multi))
|
||||
+ " §e+" + bondReward + "债券";
|
||||
}
|
||||
|
||||
|
||||
// ===== 获取连续天数 =====
|
||||
public int getStreak(String name) {
|
||||
Object val = plugin.getDb()
|
||||
@@ -111,7 +130,16 @@ public class CheckInManager {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 标准化日期
|
||||
private String normalizeDate(String input) {
|
||||
String s = input.trim().replace("/", "-");
|
||||
if (s.matches("\\d{8}")) {
|
||||
return s.substring(0, 4) + "-"
|
||||
+ s.substring(4, 6) + "-"
|
||||
+ s.substring(6, 8);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
// ===== 补签入口 =====
|
||||
public String backCheckIn(
|
||||
Player p, String dateStr) {
|
||||
@@ -130,14 +158,12 @@ public class CheckInManager {
|
||||
} catch (Exception ex) {
|
||||
return "§c无效日期: " + dateStr;
|
||||
}
|
||||
String reformat =
|
||||
sdf.format(target);
|
||||
String reformat = sdf.format(target);
|
||||
if (!reformat.equals(normalized)) {
|
||||
return "§c无效日期: " + dateStr;
|
||||
}
|
||||
|
||||
String today = sdf.format(new Date());
|
||||
|
||||
if (normalized.compareTo(today) >= 0) {
|
||||
return "§c今天不需要补签";
|
||||
}
|
||||
@@ -146,19 +172,16 @@ public class CheckInManager {
|
||||
if (normalized.compareTo(
|
||||
threeDaysAgo) < 0) {
|
||||
return "§c只能补签3天内"
|
||||
+ "(最多到 "
|
||||
+ threeDaysAgo + ")";
|
||||
+ "(最多到 " + threeDaysAgo + ")";
|
||||
}
|
||||
|
||||
Object val = plugin.getDb()
|
||||
.getField(name,
|
||||
"backcheck_dates");
|
||||
.getField(name, "backcheck_dates");
|
||||
String existing = val != null
|
||||
? val.toString() : "";
|
||||
boolean alreadyBacked = false;
|
||||
if (!existing.isEmpty()) {
|
||||
for (String d
|
||||
: existing.split(",")) {
|
||||
for (String d : existing.split(",")) {
|
||||
if (normalizeDate(d.trim())
|
||||
.equals(normalized)) {
|
||||
alreadyBacked = true;
|
||||
@@ -167,71 +190,94 @@ public class CheckInManager {
|
||||
}
|
||||
}
|
||||
if (alreadyBacked) {
|
||||
return "§c该日期已补签: "
|
||||
+ normalized;
|
||||
return "§c该日期已补签: " + normalized;
|
||||
}
|
||||
|
||||
Object normalCheck =
|
||||
plugin.getDb().getField(
|
||||
name,
|
||||
"last_checkin_date");
|
||||
String lastCheckin = normalCheck
|
||||
!= null
|
||||
? normalCheck.toString()
|
||||
: "";
|
||||
name, "last_checkin_date");
|
||||
String lastCheckin = normalCheck != null
|
||||
? normalCheck.toString() : "";
|
||||
if (lastCheckin.equals(normalized)) {
|
||||
return "§c该日期已签到: "
|
||||
+ normalized;
|
||||
return "§c该日期已签到: " + normalized;
|
||||
}
|
||||
|
||||
if (!plugin.getDb()
|
||||
.deductPoints(name, 10)) {
|
||||
return "§c积分不足,补签需 10 积分";
|
||||
}
|
||||
// ★ 补签消耗:3债券 + 10积分
|
||||
// 积分不够时:用 9债券(3倍)代替 ★
|
||||
int bondCost = 3;
|
||||
int pointCost = 10;
|
||||
int bondAltCost = 9; // 3倍债券替代
|
||||
|
||||
net.milkbowl.vault.economy
|
||||
.Economy econ =
|
||||
plugin.getEconomy();
|
||||
if (econ != null) {
|
||||
double bal = econ.getBalance(p);
|
||||
if (bal < 10.0) {
|
||||
plugin.getDb()
|
||||
.addPoints(name, 10);
|
||||
int mult = plugin.getConfig2()
|
||||
.backCheckPointMultiplier;
|
||||
int needed = 10 * mult;
|
||||
p.sendMessage("§e§l[补签] "
|
||||
+ "§f经济不足($"
|
||||
+ String.format(
|
||||
"%.2f", bal)
|
||||
+ " < $10)");
|
||||
p.sendMessage("§7是否用 §a§l"
|
||||
+ needed + "积分 "
|
||||
+ "§7代替 10经济?");
|
||||
p.sendMessage("§7输入 §a1 "
|
||||
+ "§7同意,"
|
||||
+ "§c其他 §7取消");
|
||||
return "NEED_CONFIRM:"
|
||||
+ normalized
|
||||
+ ":" + needed;
|
||||
int myBonds = plugin.getBonds()
|
||||
.getBonds(name);
|
||||
Object ptsObj = plugin.getDb()
|
||||
.getField(name, "points");
|
||||
int myPoints = ptsObj != null
|
||||
? ((Number) ptsObj).intValue()
|
||||
: 0;
|
||||
|
||||
|
||||
// 方案A:3债券 + 10积分
|
||||
boolean canA = myBonds >= bondCost
|
||||
&& myPoints >= pointCost;
|
||||
// 方案B:9债券(积分不够时)
|
||||
boolean canB = myBonds >= bondAltCost
|
||||
&& myPoints < pointCost;
|
||||
|
||||
if (!canA && !canB) {
|
||||
if (myPoints < pointCost
|
||||
&& myBonds < bondAltCost) {
|
||||
return "§c补签费用不足"
|
||||
+ "(需3债券+10积分,"
|
||||
+ "或9债券代替)"
|
||||
+ " 当前: "
|
||||
+ myBonds + "债券 "
|
||||
+ myPoints + "积分";
|
||||
}
|
||||
econ.withdrawPlayer(p, 10);
|
||||
if (myBonds < bondCost) {
|
||||
return "§c债券不足(需"
|
||||
+ bondCost + "枚)";
|
||||
}
|
||||
return "§c积分不足(需"
|
||||
+ pointCost + "分)";
|
||||
}
|
||||
|
||||
// 执行扣款
|
||||
if (canA) {
|
||||
// 方案A:3债券 + 10积分
|
||||
plugin.getBonds().deductBonds(
|
||||
name, bondCost,
|
||||
"admin_deduct", "",
|
||||
"补签系统", "补签扣款");
|
||||
plugin.getDb().deductPoints(
|
||||
name, pointCost);
|
||||
} else {
|
||||
// 方案B:9债券代替
|
||||
plugin.getBonds().deductBonds(
|
||||
name, bondAltCost,
|
||||
"admin_deduct", "",
|
||||
"补签系统", "补签扣款(债券替代)");
|
||||
}
|
||||
|
||||
doBackCheckIn(p, normalized);
|
||||
|
||||
String costMsg = canA
|
||||
? bondCost + "债券+" + pointCost + "积分"
|
||||
: bondAltCost + "债券(替代)";
|
||||
return "§a补签成功!日期: "
|
||||
+ normalized
|
||||
+ " §7(扣除10积分+10经济)";
|
||||
+ " §7消耗: " + costMsg;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ===== 执行补签 =====
|
||||
// ===== 执行补签 =====
|
||||
public void doBackCheckIn(
|
||||
Player p, String dateStr) {
|
||||
String name = p.getName();
|
||||
Object val = plugin.getDb()
|
||||
.getField(name,
|
||||
"backcheck_dates");
|
||||
.getField(name, "backcheck_dates");
|
||||
String existing = val != null
|
||||
? val.toString() : "";
|
||||
String updated = existing.isEmpty()
|
||||
@@ -241,25 +287,8 @@ public class CheckInManager {
|
||||
"backcheck_dates", updated);
|
||||
int streak = getStreak(name);
|
||||
plugin.getDb().setField(name,
|
||||
"checkin_streak",
|
||||
streak + 1);
|
||||
int bondReward = 1;
|
||||
if (streak >= 21) bondReward = 3;
|
||||
else if (streak >= 14) bondReward = 2;
|
||||
else if (streak >= 7) bondReward = 2;
|
||||
plugin.getBonds().addBonds(name, bondReward);
|
||||
}
|
||||
// ===== 标准化日期 =====
|
||||
private String normalizeDate(
|
||||
String input) {
|
||||
String s = input.trim()
|
||||
.replace("/", "-");
|
||||
if (s.matches("\\d{8}")) {
|
||||
return s.substring(0, 4) + "-"
|
||||
+ s.substring(4, 6) + "-"
|
||||
+ s.substring(6, 8);
|
||||
}
|
||||
return s;
|
||||
"checkin_streak", streak + 1);
|
||||
// ★ 补签不发放奖励,只记录
|
||||
}
|
||||
|
||||
// ===== 获取N天前日期 =====
|
||||
|
||||
@@ -149,18 +149,8 @@ public class CommissionManager {
|
||||
originalAmount * rate;
|
||||
if (commission <= 0) return;
|
||||
|
||||
// 通过 Vault 发放
|
||||
var rsp = main.getServer()
|
||||
.getServicesManager()
|
||||
.getRegistration(
|
||||
net.milkbowl.vault
|
||||
.economy.Economy.class);
|
||||
if (rsp == null) return;
|
||||
net.milkbowl.vault.economy.Economy econ =
|
||||
rsp.getProvider();
|
||||
if (econ == null) return;
|
||||
|
||||
econ.depositPlayer(inviter, commission);
|
||||
// Economy已移除,佣金提成功能不可用
|
||||
// 邀请人可通过债券系统获取奖励
|
||||
|
||||
// 通知邀请人
|
||||
Player inviterP =
|
||||
|
||||
@@ -67,14 +67,12 @@ public class CypayCommand
|
||||
+ " 余额: §a" + bonds);
|
||||
sender.sendMessage(
|
||||
"§e§l[债券] §f状态: "
|
||||
+ ("frozen".equals(status)
|
||||
? "§c冻结中"
|
||||
: "§a正常"));
|
||||
+ freezeText(name));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== /cypay import =====
|
||||
if (args.length == 2
|
||||
/* if (args.length == 2
|
||||
&& "import".equalsIgnoreCase(
|
||||
args[0])) {
|
||||
if (!hasAdminPerm(sender)) {
|
||||
@@ -93,10 +91,12 @@ public class CypayCommand
|
||||
sender.sendMessage("§a导入完成: "
|
||||
+ result + " 条");
|
||||
return true;
|
||||
}
|
||||
}*/
|
||||
|
||||
// ===== /cypay freeze =====
|
||||
if (args.length == 2
|
||||
// ===== /cypay freeze <玩家> [时长] =====
|
||||
// ★ 2026-10-04 改版:时长可选,硬编封顶 30 天,
|
||||
// 不传时长 = 永久冻结;支持 1分钟/壹分钟/one min/时间戳
|
||||
if (args.length >= 2
|
||||
&& "freeze".equalsIgnoreCase(
|
||||
args[0])) {
|
||||
if (!hasAdminPerm(sender)) {
|
||||
@@ -104,20 +104,57 @@ public class CypayCommand
|
||||
return true;
|
||||
}
|
||||
String target = args[1];
|
||||
if (plugin.getBonds().isFrozen(target)) {
|
||||
sender.sendMessage("§c" + target
|
||||
+ " 已处于冻结状态");
|
||||
return true;
|
||||
long until = 0L; // 0 = 永久冻结
|
||||
String durNote = "永久";
|
||||
if (args.length >= 3) {
|
||||
String durStr = String.join(" ",
|
||||
Arrays.copyOfRange(args, 2,
|
||||
args.length));
|
||||
long expireMs;
|
||||
try {
|
||||
expireMs = DurationParser
|
||||
.parseToExpireMs(durStr);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
sender.sendMessage("§c时长格式无法解析: "
|
||||
+ ex.getMessage());
|
||||
sender.sendMessage("§7可用写法: 30分钟 / "
|
||||
+ "壹小时 / one min / 7天 / "
|
||||
+ "253402271999(时间戳)");
|
||||
sender.sendMessage("§7不传时长 = 永久冻结,"
|
||||
+ "时长封顶 30 天");
|
||||
return true;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
long MAX_MS =
|
||||
30L * 24 * 3600 * 1000L; // 封顶30天
|
||||
if (expireMs <= now) {
|
||||
// 过期/为零的时间戳(如 0000000000)-> 永久
|
||||
sender.sendMessage("§7该时间点已过期,"
|
||||
+ "按 §c永久冻结 §7处理");
|
||||
until = 0L;
|
||||
} else if (expireMs - now > MAX_MS) {
|
||||
until = now + MAX_MS;
|
||||
sender.sendMessage("§7时长超过 30 天,"
|
||||
+ "已按封顶 §e30天 §7处理");
|
||||
} else {
|
||||
until = expireMs;
|
||||
}
|
||||
durNote = until <= 0L ? "永久"
|
||||
: "至 " + DurationParser
|
||||
.formatExpire(until);
|
||||
}
|
||||
plugin.getBonds().freezeAccount(target);
|
||||
plugin.getBonds().freezeAccount(target, until);
|
||||
sender.sendMessage("§a已冻结 " + target
|
||||
+ " 的债券账户");
|
||||
+ " 的债券账户(" + durNote + ")");
|
||||
Player tp = plugin.getServer()
|
||||
.getPlayerExact(target);
|
||||
if (tp != null)
|
||||
tp.sendMessage(
|
||||
"§c§l[系统] 你的债券账户"
|
||||
+ "已被管理员冻结");
|
||||
"§c§l[系统] 你的债券账户已被管理员冻结"
|
||||
+ (until <= 0L ? ""
|
||||
: ",到期时间: "
|
||||
+ DurationParser
|
||||
.formatExpire(until)));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -182,9 +219,7 @@ public class CypayCommand
|
||||
+ plugin.getBonds()
|
||||
.getBonds(target)
|
||||
+ " §f状态: "
|
||||
+ (plugin.getBonds()
|
||||
.isFrozen(target)
|
||||
? "§c冻结" : "§a正常"));
|
||||
+ freezeText(target));
|
||||
|
||||
if (txs.isEmpty()) {
|
||||
sender.sendMessage("§7 (暂无记录)");
|
||||
@@ -241,7 +276,7 @@ public class CypayCommand
|
||||
}
|
||||
|
||||
// ===== /cypay give/remove =====
|
||||
if (args.length == 3) {
|
||||
if (args.length >= 3 && args.length <= 4) {
|
||||
String action = args[0].toLowerCase();
|
||||
if ("give".equals(action)
|
||||
|| "remove".equals(action)) {
|
||||
@@ -268,12 +303,19 @@ public class CypayCommand
|
||||
sender instanceof Player
|
||||
? sender.getName()
|
||||
: "Console";
|
||||
// 自定义理由(可选)
|
||||
String reason = args.length == 4
|
||||
? args[3]
|
||||
: ("give".equals(action)
|
||||
? "管理员发放"
|
||||
: "管理员扣除");
|
||||
|
||||
if ("give".equals(action)) {
|
||||
plugin.getBonds().addBonds(
|
||||
// ★ 冻结期豁免:管理员手动 give 允许动账
|
||||
plugin.getBonds().addBondsAdmin(
|
||||
target, amount,
|
||||
txType, "",
|
||||
opName, "管理员发放");
|
||||
opName, reason);
|
||||
sender.sendMessage(
|
||||
"§a已给 " + target
|
||||
+ " " + amount
|
||||
@@ -286,12 +328,14 @@ public class CypayCommand
|
||||
+ amount
|
||||
+ " 债券");
|
||||
} else {
|
||||
// ★ 冻结期豁免:管理员手动 remove
|
||||
// 允许动账(deductBondsAdmin 不查冻结)
|
||||
if (plugin.getBonds()
|
||||
.deductBonds(target,
|
||||
.deductBondsAdmin(target,
|
||||
amount,
|
||||
txType, "",
|
||||
opName,
|
||||
"管理员扣除")) {
|
||||
reason)) {
|
||||
sender.sendMessage(
|
||||
"§a已扣除 " + target
|
||||
+ " " + amount
|
||||
@@ -309,8 +353,7 @@ public class CypayCommand
|
||||
} else {
|
||||
sender.sendMessage(
|
||||
"§c" + target
|
||||
+ " 债券不足"
|
||||
+ "或账户冻结");
|
||||
+ " 债券不足");
|
||||
}
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
@@ -368,6 +411,14 @@ public class CypayCommand
|
||||
// ===== /cypay <player> — 查指定玩家 =====
|
||||
if (args.length == 1) {
|
||||
String target = args[0];
|
||||
// ★ 验证玩家在 login.db 中存在 ★
|
||||
if (!plugin.getDb().userExists(target)) {
|
||||
sender.sendMessage("§c玩家 " + target
|
||||
+ " 不存在于服务器");
|
||||
return true;
|
||||
}
|
||||
|
||||
// String target = args[0];
|
||||
if (hasAdminPerm(sender)) {
|
||||
int bonds = plugin.getBonds()
|
||||
.getBonds(target);
|
||||
@@ -379,10 +430,7 @@ public class CypayCommand
|
||||
+ bonds);
|
||||
sender.sendMessage(
|
||||
"§e§l[债券] §f状态: "
|
||||
+ ("frozen".equals(
|
||||
status)
|
||||
? "§c冻结中"
|
||||
: "§a正常"));
|
||||
+ freezeText(target));
|
||||
} else {
|
||||
int bonds = plugin.getBonds()
|
||||
.getBonds(
|
||||
@@ -398,6 +446,19 @@ public class CypayCommand
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== 冻结状态文案(含到期时间) =====
|
||||
|
||||
/** 正常 / 冻结中(永久) / 冻结中(至 yyyy年M月d日 HH:mm) */
|
||||
private String freezeText(String target) {
|
||||
if (!plugin.getBonds().isFrozen(target))
|
||||
return "§a正常";
|
||||
long until = plugin.getBonds()
|
||||
.getFreezeUntil(target);
|
||||
if (until <= 0L) return "§c冻结中(永久)";
|
||||
return "§c冻结中(至 "
|
||||
+ DurationParser.formatExpire(until) + ")";
|
||||
}
|
||||
|
||||
// ===== 流水行格式化 =====
|
||||
|
||||
private String formatTxLine(
|
||||
@@ -469,13 +530,14 @@ public class CypayCommand
|
||||
"§e/cypay <玩家> <金额> §7- 转账");
|
||||
if (hasAdminPerm(sender)) {
|
||||
sender.sendMessage(
|
||||
"§e/cypay give <玩家> <金额> §7- 给债券");
|
||||
"§e/cypay give <玩家> <金额> [理由] §7- 给债券");
|
||||
sender.sendMessage(
|
||||
"§e/cypay remove <玩家> <金额> §7- 扣债券");
|
||||
"§e/cypay remove <玩家> <金额> [理由] §7- 扣债券");
|
||||
sender.sendMessage(
|
||||
"§e/cypay import <文件> §7- 导入CDK");
|
||||
sender.sendMessage(
|
||||
"§e/cypay freeze <玩家> §7- 冻结");
|
||||
"§e/cypay freeze <玩家> [时长] §7- 冻结"
|
||||
+ "(不填=永久,封顶30天)");
|
||||
sender.sendMessage(
|
||||
"§e/cypay unfreeze <玩家> §7- 解冻");
|
||||
sender.sendMessage(
|
||||
@@ -545,6 +607,15 @@ public class CypayCommand
|
||||
|
||||
if (args.length == 3) {
|
||||
String first = args[0].toLowerCase();
|
||||
if ("freeze".equals(first)) {
|
||||
list.add("10分钟");
|
||||
list.add("1小时");
|
||||
list.add("one min");
|
||||
list.add("7天");
|
||||
list.add("30天");
|
||||
return filter(list,
|
||||
args[2].toLowerCase());
|
||||
}
|
||||
if ("give".equals(first)
|
||||
|| "remove".equals(first)) {
|
||||
list.add("10");
|
||||
@@ -564,6 +635,19 @@ public class CypayCommand
|
||||
}
|
||||
}
|
||||
|
||||
if (args.length == 4) {
|
||||
String first = args[0].toLowerCase();
|
||||
if ("give".equals(first)
|
||||
|| "remove".equals(first)) {
|
||||
list.add("管理员发放");
|
||||
list.add("管理员扣除");
|
||||
list.add("工单奖励");
|
||||
list.add("活动奖励");
|
||||
return filter(list,
|
||||
args[3].toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.CommandSender;
|
||||
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.PlayerDeathEvent;
|
||||
import org.bukkit.scheduler.BukkitRunnable;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.event.HoverEvent;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 死亡报告系统
|
||||
* - 记录玩家死亡坐标(一次性,新死亡覆盖)
|
||||
* - 死亡后发送包含坐标的消息
|
||||
*/
|
||||
public class DeathReport implements Listener {
|
||||
private final Main plugin;
|
||||
|
||||
// 内存缓存: player_name → DeathData
|
||||
private final ConcurrentHashMap<String, DeathData> deathReports = new ConcurrentHashMap<>();
|
||||
|
||||
public static class DeathData {
|
||||
public final String playerName;
|
||||
public String worldName;
|
||||
public final double x, y, z;
|
||||
public long deathTime;
|
||||
public final UUID playerId;
|
||||
|
||||
DeathData(String playerName, UUID playerId, Location loc) {
|
||||
this.playerName = playerName;
|
||||
this.playerId = playerId;
|
||||
this.worldName = loc.getWorld() != null ? loc.getWorld().getName() : "world";
|
||||
this.x = loc.getX();
|
||||
this.y = loc.getY();
|
||||
this.z = loc.getZ();
|
||||
this.deathTime = System.currentTimeMillis();
|
||||
}
|
||||
}
|
||||
|
||||
public DeathReport(Main plugin) {
|
||||
this.plugin = plugin;
|
||||
initTable();
|
||||
loadAllFromDB();
|
||||
}
|
||||
|
||||
// ==================== 事件监听 ====================
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerDeath(PlayerDeathEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
Location deathLoc = player.getLocation();
|
||||
if (deathLoc == null) return;
|
||||
|
||||
DeathData data = new DeathData(player.getName(), player.getUniqueId(), deathLoc);
|
||||
deathReports.put(player.getName(), data);
|
||||
saveToDB(data);
|
||||
|
||||
// 发送死亡报告消息,带可点击的返回按钮
|
||||
String timeStr = new java.text.SimpleDateFormat("HH:mm:ss").format(new java.util.Date(data.deathTime));
|
||||
|
||||
Component header = Component.text("§c✦ 你已死亡 ✦ ").append(
|
||||
Component.text("世界: §f" + data.worldName + " §| §7坐标: §e" +
|
||||
String.format("%.1f, %.1f, %.1f", data.x, data.y, data.z) + " §7时间: §f" + timeStr)
|
||||
);
|
||||
|
||||
// "返回死亡地点" 按钮 - 通过 /back 命令
|
||||
Component backButton = Component.text("§e[点击返回死亡地点] ")
|
||||
.clickEvent(ClickEvent.runCommand("/back"))
|
||||
.hoverEvent(HoverEvent.showText(Component.text("§7传送到你死亡的位置")));
|
||||
|
||||
player.sendMessage(header);
|
||||
player.sendMessage(backButton);
|
||||
}
|
||||
|
||||
// ==================== 查询/获取 ====================
|
||||
|
||||
/**
|
||||
* 获取玩家的最新死亡坐标(如果有的话)
|
||||
*/
|
||||
public DeathData getDeathReport(String playerName) {
|
||||
return deathReports.get(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除玩家的死亡报告
|
||||
*/
|
||||
public void clearDeathReport(String playerName) {
|
||||
deathReports.remove(playerName);
|
||||
}
|
||||
|
||||
// ==================== 数据库操作 ====================
|
||||
|
||||
private void loadAllFromDB() {
|
||||
try {
|
||||
java.sql.Connection conn = plugin.getDb().getConnection();
|
||||
if (conn == null) return;
|
||||
|
||||
PreparedStatement ps = conn.prepareStatement(
|
||||
"SELECT player_name, player_uuid, world, x, y, z, death_time FROM death_reports");
|
||||
ResultSet rs = ps.executeQuery();
|
||||
|
||||
while (rs.next()) {
|
||||
DeathData data = new DeathData(
|
||||
rs.getString("player_name"),
|
||||
UUID.fromString(rs.getString("player_uuid")),
|
||||
new Location(null, rs.getDouble("x"), rs.getDouble("y"), rs.getDouble("z"))
|
||||
);
|
||||
data.worldName = rs.getString("world");
|
||||
data.deathTime = rs.getLong("death_time");
|
||||
deathReports.put(data.playerName, data);
|
||||
}
|
||||
rs.close();
|
||||
ps.close();
|
||||
} catch (SQLException e) {
|
||||
// 表可能不存在,忽略(会在第一次死亡时自动创建)
|
||||
}
|
||||
}
|
||||
|
||||
private void saveToDB(DeathData data) {
|
||||
try {
|
||||
java.sql.Connection conn = plugin.getDb().getConnection();
|
||||
if (conn == null) return;
|
||||
|
||||
PreparedStatement ps = conn.prepareStatement(
|
||||
"INSERT OR REPLACE INTO death_reports (player_name, player_uuid, world, x, y, z, death_time) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||
ps.setString(1, data.playerName);
|
||||
ps.setString(2, data.playerId.toString());
|
||||
ps.setString(3, data.worldName);
|
||||
ps.setDouble(4, data.x);
|
||||
ps.setDouble(5, data.y);
|
||||
ps.setDouble(6, data.z);
|
||||
ps.setLong(7, data.deathTime);
|
||||
ps.executeUpdate();
|
||||
ps.close();
|
||||
} catch (SQLException e) {
|
||||
plugin.getLogger().warning("[死亡报告] 保存失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void persistAll() {
|
||||
for (DeathData data : deathReports.values()) {
|
||||
saveToDB(data);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
public void initTable() {
|
||||
try {
|
||||
java.sql.Connection conn = plugin.getDb().getConnection();
|
||||
if (conn == null) return;
|
||||
|
||||
java.sql.Statement st = conn.createStatement();
|
||||
st.execute("CREATE TABLE IF NOT EXISTS death_reports ("
|
||||
+ "player_name TEXT PRIMARY KEY, "
|
||||
+ "player_uuid TEXT NOT NULL, "
|
||||
+ "world TEXT, "
|
||||
+ "x REAL, y REAL, z REAL, "
|
||||
+ "death_time LONG)");
|
||||
st.close();
|
||||
} catch (SQLException e) {
|
||||
plugin.getLogger().warning("[死亡报告] 建表失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import java.util.Calendar;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 统一时长解析器(供 /tempban 与 /chat mute 共用)
|
||||
*
|
||||
* parseToExpireMs(String) 返回「绝对到期时间戳(毫秒)」,调用方自行校验最小/最大区间。
|
||||
*
|
||||
* 支持的写法:
|
||||
* 1) 纯数字(Unix 秒时间戳): 253402271999
|
||||
* 2) 中文日期(绝对): 2026年7月9日
|
||||
* 3) ISO 日期(绝对): 2025-07-09
|
||||
* 4) 相对时长(中文/英文单位): 1秒 / 1s / 1年 / 1年2月3天 / 2天12小时
|
||||
* 5) 英文数字词(2026-10-04 新增,供 /cypay freeze 用):
|
||||
* one min / two hours / twenty days / twenty one minutes
|
||||
*
|
||||
* <p>注意:纯数字仍按「Unix 秒时间戳」处理(如 0000000000);
|
||||
* 过期时间戳由调用方自行判定(/cypay freeze 视为永久冻结)。
|
||||
*
|
||||
* 解析规则优先级:纯数字 → 时间戳;含「年/月/日」且为完整日期格式 → 绝对日期;
|
||||
* 否则按「数字+单位」逐段累加为相对时长。
|
||||
*/
|
||||
public class DurationParser {
|
||||
|
||||
private static final Map<String, Long> UNIT_MS = new HashMap<>();
|
||||
static {
|
||||
// 秒
|
||||
UNIT_MS.put("秒", 1000L);
|
||||
UNIT_MS.put("秒钟", 1000L);
|
||||
UNIT_MS.put("s", 1000L);
|
||||
UNIT_MS.put("sec", 1000L);
|
||||
// 分
|
||||
UNIT_MS.put("分", 60000L);
|
||||
UNIT_MS.put("分钟", 60000L);
|
||||
UNIT_MS.put("mins", 60000L);
|
||||
UNIT_MS.put("min", 60000L);
|
||||
UNIT_MS.put("m", 60000L);
|
||||
// 时
|
||||
UNIT_MS.put("时", 3600000L);
|
||||
UNIT_MS.put("小时", 3600000L);
|
||||
UNIT_MS.put("钟头", 3600000L);
|
||||
UNIT_MS.put("h", 3600000L);
|
||||
UNIT_MS.put("hr", 3600000L);
|
||||
// 天
|
||||
UNIT_MS.put("天", 86400000L);
|
||||
UNIT_MS.put("日", 86400000L);
|
||||
UNIT_MS.put("d", 86400000L);
|
||||
// 周
|
||||
UNIT_MS.put("周", 604800000L);
|
||||
UNIT_MS.put("星期", 604800000L);
|
||||
UNIT_MS.put("礼拜", 604800000L);
|
||||
UNIT_MS.put("w", 604800000L);
|
||||
// 月(按30天近似)
|
||||
UNIT_MS.put("个月", 2592000000L);
|
||||
UNIT_MS.put("月", 2592000000L);
|
||||
UNIT_MS.put("mo", 2592000000L);
|
||||
// 年(按365天近似)
|
||||
UNIT_MS.put("年", 31536000000L);
|
||||
UNIT_MS.put("y", 31536000000L);
|
||||
}
|
||||
|
||||
/** 解析为绝对到期毫秒;无法解析抛出 IllegalArgumentException */
|
||||
public static long parseToExpireMs(String input)
|
||||
throws IllegalArgumentException {
|
||||
if (input == null || input.trim().isEmpty())
|
||||
throw new IllegalArgumentException("时长为空");
|
||||
String in = input.trim();
|
||||
|
||||
// ★ 0) 日期+时间(绝对):26-10-08 14:43 / 2026-10-08 14:43 / 2026年10月8日 14:43
|
||||
// 必须在去空格之前解析:否则日期与时间粘连(26-10-08 14:43 → 26-10-0814:43)无法区分
|
||||
long dt = parseDateTime(in);
|
||||
if (dt >= 0) return dt;
|
||||
|
||||
// ★ 先把英文数字词换成阿拉伯数字(one min -> 1 min)
|
||||
String s = normalizeEnglishNumber(in).replace(" ", "");
|
||||
|
||||
// 1) 纯数字 → Unix 秒时间戳
|
||||
if (s.matches("\\d+")) {
|
||||
long ts = Long.parseLong(s);
|
||||
return ts * 1000L;
|
||||
}
|
||||
|
||||
// 2) 中文日期 YYYY年M月D日
|
||||
Matcher mc = Pattern.compile(
|
||||
"(\\d{4})年(\\d{1,2})月(\\d{1,2})日").matcher(s);
|
||||
if (mc.find()) {
|
||||
return buildDateMillis(Integer.parseInt(mc.group(1)),
|
||||
Integer.parseInt(mc.group(2)),
|
||||
Integer.parseInt(mc.group(3)));
|
||||
}
|
||||
|
||||
// 3) ISO 日期 YYYY-MM-DD / YYYY/MM/DD
|
||||
Matcher mi = Pattern.compile(
|
||||
"(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})").matcher(s);
|
||||
if (mi.find()) {
|
||||
return buildDateMillis(Integer.parseInt(mi.group(1)),
|
||||
Integer.parseInt(mi.group(2)),
|
||||
Integer.parseInt(mi.group(3)));
|
||||
}
|
||||
|
||||
// 4) 相对时长(可多段组合)
|
||||
long rel = parseRelative(s);
|
||||
if (rel >= 0) {
|
||||
return System.currentTimeMillis() + rel;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("无法解析时长: " + input);
|
||||
}
|
||||
|
||||
/** 英文数字词 -> 阿拉伯数字(zero..ninety,支持 twenty one / twenty-one 连写) */
|
||||
private static String normalizeEnglishNumber(String in) {
|
||||
try {
|
||||
String[] words = {"zero", "one", "two", "three", "four",
|
||||
"five", "six", "seven", "eight", "nine", "ten",
|
||||
"eleven", "twelve", "thirteen", "fourteen",
|
||||
"fifteen", "sixteen", "seventeen", "eighteen",
|
||||
"nineteen", "twenty", "thirty", "forty", "fifty",
|
||||
"sixty", "seventy", "eighty", "ninety"};
|
||||
StringBuilder alt = new StringBuilder();
|
||||
for (int i = 0; i < words.length; i++) {
|
||||
if (i > 0) alt.append('|');
|
||||
alt.append(words[i]);
|
||||
}
|
||||
Pattern p = Pattern.compile(
|
||||
"(?i)\\b(" + alt + ")(?:[\\s-]+(" + alt + "))?\\b");
|
||||
Matcher m = p.matcher(in);
|
||||
StringBuffer sb = new StringBuffer();
|
||||
while (m.find()) {
|
||||
int v = enWordValue(m.group(1));
|
||||
if (m.group(2) != null) v += enWordValue(m.group(2));
|
||||
m.appendReplacement(sb, Matcher.quoteReplacement(
|
||||
String.valueOf(v)));
|
||||
}
|
||||
m.appendTail(sb);
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return in; // 归一化失败就原样返回
|
||||
}
|
||||
}
|
||||
|
||||
private static int enWordValue(String w) {
|
||||
switch (w.toLowerCase(java.util.Locale.ROOT)) {
|
||||
case "zero": return 0;
|
||||
case "one": return 1;
|
||||
case "two": return 2;
|
||||
case "three": return 3;
|
||||
case "four": return 4;
|
||||
case "five": return 5;
|
||||
case "six": return 6;
|
||||
case "seven": return 7;
|
||||
case "eight": return 8;
|
||||
case "nine": return 9;
|
||||
case "ten": return 10;
|
||||
case "eleven": return 11;
|
||||
case "twelve": return 12;
|
||||
case "thirteen": return 13;
|
||||
case "fourteen": return 14;
|
||||
case "fifteen": return 15;
|
||||
case "sixteen": return 16;
|
||||
case "seventeen": return 17;
|
||||
case "eighteen": return 18;
|
||||
case "nineteen": return 19;
|
||||
case "twenty": return 20;
|
||||
case "thirty": return 30;
|
||||
case "forty": return 40;
|
||||
case "fifty": return 50;
|
||||
case "sixty": return 60;
|
||||
case "seventy": return 70;
|
||||
case "eighty": return 80;
|
||||
case "ninety": return 90;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 日期+时间绝对解析(2026-10-08 新增:支持 26-10-08 14:43 写法) =====
|
||||
|
||||
/** 中文日期+时间:2026年10月8日 14:43(“日”已界定日期边界,时间可紧贴) */
|
||||
private static final Pattern DT_CN = Pattern.compile(
|
||||
"(\\d{4})年(\\d{1,2})月(\\d{1,2})日(?:[ T\\u3000]+)?"
|
||||
+ "(\\d{1,2}):(\\d{2})(?::(\\d{2}))?(?!\\d|[A-Za-z])");
|
||||
|
||||
/** 分隔日期+时间:26-10-08 14:43 / 2026-10-08 14:43 / 2026.10.08T14:43:00
|
||||
* 两位年份按 20xx 解释;日期与时间之间必须有空格或 T,
|
||||
* 防止 2026-1-8 14:43 被粘连误读成「日=81、时=4」 */
|
||||
private static final Pattern DT_SEP = Pattern.compile(
|
||||
"(?<!\\d)(\\d{4}|\\d{2})[-/.](\\d{1,2})[-/.](\\d{1,2})"
|
||||
+ "[ T\\u3000]+(\\d{1,2}):(\\d{2})(?::(\\d{2}))?(?!\\d|[A-Za-z])");
|
||||
|
||||
/** 两位年份无时间:26-10-08
|
||||
* lookbehind 防止吃掉 4 位年份的后半段(2026-07-09 不能被读成 07-09) */
|
||||
private static final Pattern DT_YMD2 = Pattern.compile(
|
||||
"(?<!\\d{2}[-/.])(\\d{2})[-/.](\\d{1,2})[-/.](\\d{1,2})(?!\\d|[A-Za-z])");
|
||||
|
||||
/** 解析「日期+时间」为绝对毫秒;认不出来返回 -1 交后续分支处理 */
|
||||
private static long parseDateTime(String in) {
|
||||
Matcher m = DT_CN.matcher(in);
|
||||
if (m.find()) {
|
||||
return buildDateTimeMillis(Integer.parseInt(m.group(1)),
|
||||
Integer.parseInt(m.group(2)), Integer.parseInt(m.group(3)),
|
||||
timeOf(m.group(4), m.group(5), m.group(6)));
|
||||
}
|
||||
m = DT_SEP.matcher(in);
|
||||
if (m.find()) {
|
||||
int year = Integer.parseInt(m.group(1));
|
||||
if (year < 100) year += 2000; // 26 → 2026
|
||||
return buildDateTimeMillis(year, Integer.parseInt(m.group(2)),
|
||||
Integer.parseInt(m.group(3)),
|
||||
timeOf(m.group(4), m.group(5), m.group(6)));
|
||||
}
|
||||
m = DT_YMD2.matcher(in);
|
||||
if (m.find()) {
|
||||
return buildDateTimeMillis(2000 + Integer.parseInt(m.group(1)),
|
||||
Integer.parseInt(m.group(2)), Integer.parseInt(m.group(3)),
|
||||
new int[]{-1, -1, -1});
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 解析 HH:mm[:ss];无时间(null)返回 {-1,-1,-1} 表示取 00:00:00 */
|
||||
private static int[] timeOf(String h, String mi, String s) {
|
||||
if (h == null) return new int[]{-1, -1, -1};
|
||||
int H = Integer.parseInt(h), M = Integer.parseInt(mi);
|
||||
int S = (s == null) ? 0 : Integer.parseInt(s);
|
||||
if (H > 23 || M > 59 || S > 59)
|
||||
throw new IllegalArgumentException("无效的时间: " + h + ":" + mi);
|
||||
return new int[]{H, M, S};
|
||||
}
|
||||
|
||||
/** 绝对日期+时间(t[0..2] 为 -1 表示 00:00:00) */
|
||||
private static long buildDateTimeMillis(int y, int mo, int d, int[] t) {
|
||||
if (mo < 1 || mo > 12 || d < 1 || d > 31)
|
||||
throw new IllegalArgumentException("无效的日期: " + y + "-" + mo + "-" + d);
|
||||
Calendar c = Calendar.getInstance();
|
||||
c.set(y, mo - 1, d,
|
||||
t[0] < 0 ? 0 : t[0],
|
||||
t[1] < 0 ? 0 : t[1],
|
||||
t[2] < 0 ? 0 : t[2]);
|
||||
c.set(Calendar.MILLISECOND, 0);
|
||||
return c.getTimeInMillis();
|
||||
}
|
||||
|
||||
private static long parseRelative(String s) {
|
||||
// 按「数字 + 单位」逐段匹配并累加
|
||||
StringBuilder units = new StringBuilder();
|
||||
for (String u : UNIT_MS.keySet()) {
|
||||
if (units.length() > 0) units.append("|");
|
||||
// 转义,避免正则特殊字符
|
||||
units.append(Pattern.quote(u));
|
||||
}
|
||||
Pattern p = Pattern.compile(
|
||||
"([0-9零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖]+)(" + units + ")");
|
||||
Matcher m = p.matcher(s);
|
||||
long total = 0;
|
||||
int found = 0;
|
||||
while (m.find()) {
|
||||
found++;
|
||||
long num = parseNumber(m.group(1));
|
||||
Long u = UNIT_MS.get(m.group(2));
|
||||
if (u == null) continue;
|
||||
total += num * u;
|
||||
}
|
||||
return found > 0 ? total : -1;
|
||||
}
|
||||
|
||||
private static long buildDateMillis(int y, int mo, int d) {
|
||||
Calendar c = Calendar.getInstance();
|
||||
c.set(y, mo - 1, d, 0, 0, 0);
|
||||
c.set(Calendar.MILLISECOND, 0);
|
||||
return c.getTimeInMillis();
|
||||
}
|
||||
|
||||
/** 解析数字(阿拉伯数字优先,其次中文数字 零-九/十百千万/两/壹贰叁…) */
|
||||
private static long parseNumber(String s) {
|
||||
if (s.matches("\\d+")) return Long.parseLong(s);
|
||||
|
||||
Map<Character, Long> cn = new HashMap<>();
|
||||
cn.put('零', 0L); cn.put('〇', 0L);
|
||||
cn.put('一', 1L); cn.put('壹', 1L);
|
||||
cn.put('二', 2L); cn.put('贰', 2L); cn.put('两', 2L);
|
||||
cn.put('三', 3L); cn.put('叁', 3L);
|
||||
cn.put('四', 4L); cn.put('肆', 4L);
|
||||
cn.put('五', 5L); cn.put('伍', 5L);
|
||||
cn.put('六', 6L); cn.put('陆', 6L);
|
||||
cn.put('七', 7L); cn.put('柒', 7L);
|
||||
cn.put('八', 8L); cn.put('捌', 8L);
|
||||
cn.put('九', 9L); cn.put('玖', 9L);
|
||||
|
||||
long section = 0, num = 0;
|
||||
boolean hasNum = false;
|
||||
for (char ch : s.toCharArray()) {
|
||||
if (cn.containsKey(ch)) {
|
||||
num = cn.get(ch);
|
||||
hasNum = true;
|
||||
} else if (ch == '十') {
|
||||
if (!hasNum) num = 1;
|
||||
section += num * 10; hasNum = false; num = 0;
|
||||
} else if (ch == '百') {
|
||||
if (!hasNum) num = 1;
|
||||
section += num * 100; hasNum = false; num = 0;
|
||||
} else if (ch == '千') {
|
||||
if (!hasNum) num = 1;
|
||||
section += num * 1000; hasNum = false; num = 0;
|
||||
} else if (ch == '万') {
|
||||
if (!hasNum) num = 1;
|
||||
section = (section + num) * 10000; hasNum = false; num = 0;
|
||||
} else {
|
||||
num = 0;
|
||||
}
|
||||
}
|
||||
return section + (hasNum ? num : 0);
|
||||
}
|
||||
|
||||
/** 格式化绝对到期时间戳为「yyyy年M月d日 HH:mm」 */
|
||||
public static String formatExpire(long ms) {
|
||||
Calendar c = Calendar.getInstance();
|
||||
c.setTimeInMillis(ms);
|
||||
return String.format("%d年%d月%d日 %02d:%02d",
|
||||
c.get(Calendar.YEAR),
|
||||
c.get(Calendar.MONTH) + 1,
|
||||
c.get(Calendar.DAY_OF_MONTH),
|
||||
c.get(Calendar.HOUR_OF_DAY),
|
||||
c.get(Calendar.MINUTE));
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,15 @@ public class EmailManager {
|
||||
+ "\n请尽快登录并修改密码!");
|
||||
}
|
||||
|
||||
public boolean sendBody(String to, String subject, String body) {
|
||||
if (!isConfigured()) {
|
||||
logger.warning("[Sdf1_login] SMTP未配置");
|
||||
return false;
|
||||
}
|
||||
return sendEmailSync(to, subject, body);
|
||||
}
|
||||
|
||||
|
||||
private boolean sendEmailSync(String to,
|
||||
String subject, String body) {
|
||||
final boolean[] result = {false};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Item;
|
||||
@@ -12,7 +11,6 @@ import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.scheduler.BukkitRunnable;
|
||||
|
||||
import java.io.*;
|
||||
import java.lang.reflect.Type;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.*;
|
||||
import java.sql.Date;
|
||||
@@ -39,8 +37,9 @@ public class GarbageManager {
|
||||
try {
|
||||
Class.forName("org.sqlite.JDBC");
|
||||
File dbFile = new File(
|
||||
plugin.getDataFolder(),
|
||||
new File(plugin.getDataFolder(), "垃圾箱"),
|
||||
"garbage.db");
|
||||
dbFile.getParentFile().mkdirs();
|
||||
db = DriverManager.getConnection(
|
||||
"jdbc:sqlite:"
|
||||
+ dbFile.getAbsolutePath());
|
||||
@@ -175,41 +174,56 @@ public class GarbageManager {
|
||||
}
|
||||
|
||||
private void doCleanup() {
|
||||
// 删除过期轮次的物品
|
||||
int deleteRound =
|
||||
// ★ 过期轮次删除是阻塞 DB -> 先异步执行;
|
||||
// 轮次递增 / 实体收集 / 广播 必须主线程 -> 异步完成后回主线程
|
||||
final int deleteRound =
|
||||
currentRound - maxRounds;
|
||||
if (deleteRound >= 0) {
|
||||
int deleted = deleteOldItems(
|
||||
deleteRound);
|
||||
if (deleted > 0) {
|
||||
broadcast("§7[垃圾站] §f永久清理了 §e"
|
||||
+ deleted + " §f个过期物品");
|
||||
}
|
||||
}
|
||||
Bukkit.getScheduler().runTaskAsynchronously(
|
||||
plugin, () -> {
|
||||
final int deleted =
|
||||
deleteRound >= 0
|
||||
? deleteOldItems(deleteRound)
|
||||
: 0;
|
||||
Bukkit.getScheduler().runTask(
|
||||
plugin, () -> {
|
||||
if (deleted > 0) {
|
||||
broadcast("§7[垃圾站] §f永久清理了 §e"
|
||||
+ deleted + " §f个过期物品");
|
||||
}
|
||||
|
||||
// 递增轮次
|
||||
currentRound++;
|
||||
setConfigValue("round",
|
||||
String.valueOf(currentRound));
|
||||
// 递增轮次
|
||||
currentRound++;
|
||||
final int roundNow =
|
||||
currentRound;
|
||||
// ★ 轮次落库是 DB -> 异步
|
||||
Bukkit.getScheduler()
|
||||
.runTaskAsynchronously(
|
||||
plugin, () ->
|
||||
setConfigValue("round",
|
||||
String.valueOf(roundNow)));
|
||||
|
||||
// 收集当前掉落物
|
||||
int count = collectItems();
|
||||
broadcast("§a§l[垃圾站] §f清理完成,"
|
||||
+ "共清理 §e" + count
|
||||
+ " §f个掉落物,存入回收站");
|
||||
// 收集当前掉落物
|
||||
// (实体移除必须主线程,落库已在内部转异步)
|
||||
int count = collectItems();
|
||||
broadcast("§a§l[垃圾站] §f清理完成,"
|
||||
+ "共清理 §e" + count
|
||||
+ " §f个掉落物,存入回收站");
|
||||
|
||||
// 打印下次清理时间
|
||||
long nextMs = System.currentTimeMillis()
|
||||
+ (long) cleanupInterval * 1000L;
|
||||
String nextTime =
|
||||
new SimpleDateFormat("HH:mm:ss")
|
||||
.format(new Date(nextMs));
|
||||
broadcast("§e§l[垃圾站] §f下次清理时间: §a"
|
||||
+ nextTime);
|
||||
// 打印下次清理时间
|
||||
long nextMs = System.currentTimeMillis()
|
||||
+ (long) cleanupInterval * 1000L;
|
||||
String nextTime =
|
||||
new SimpleDateFormat("HH:mm:ss")
|
||||
.format(new Date(nextMs));
|
||||
broadcast("§e§l[垃圾站] §f下次清理时间: §a"
|
||||
+ nextTime);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public int collectItems() {
|
||||
int count = 0;
|
||||
List<ItemStack> picked = new ArrayList<>();
|
||||
for (org.bukkit.World w :
|
||||
Bukkit.getWorlds()) {
|
||||
Collection<Item> items =
|
||||
@@ -221,16 +235,31 @@ public class GarbageManager {
|
||||
|| stack.getType()
|
||||
== Material.AIR)
|
||||
continue;
|
||||
saveItem(stack);
|
||||
// ★ 先取快照再移除实体(实体移除必须主线程)
|
||||
picked.add(stack.clone());
|
||||
item.remove();
|
||||
count++;
|
||||
}
|
||||
}
|
||||
// ★ 落库是阻塞 DB(含 SN 脱管登记)-> 丢异步批量写
|
||||
if (!picked.isEmpty()) {
|
||||
final List<ItemStack> toSave = picked;
|
||||
Bukkit.getScheduler()
|
||||
.runTaskAsynchronously(plugin, () -> {
|
||||
for (ItemStack s : toSave)
|
||||
saveItem(s);
|
||||
});
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public void saveItem(ItemStack stack) {
|
||||
try {
|
||||
// ★ SN 物品进垃圾箱 = 脱离玩家本人管控,起算 12 小时找回期。
|
||||
// shift 投入 / 光标放入 / 拖拽 / 扫地机收地面掉落全走这里。
|
||||
if (plugin.getSnManager() != null) {
|
||||
plugin.getSnManager().onConfiscated(stack, "垃圾站");
|
||||
}
|
||||
String data = stackToBase64(stack);
|
||||
if (data.isEmpty()) return;
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
@@ -287,11 +316,8 @@ public class GarbageManager {
|
||||
.decode(base64);
|
||||
String json = new String(bytes,
|
||||
StandardCharsets.UTF_8);
|
||||
Type type = new TypeToken<
|
||||
Map<String, Object>>() {}
|
||||
.getType();
|
||||
Map<String, Object> map =
|
||||
new Gson().fromJson(json, type);
|
||||
new Gson().fromJson(json, Map.class);
|
||||
map = fixNumberTypes(map);
|
||||
return ItemStack.deserialize(map);
|
||||
} catch (Exception e) {
|
||||
@@ -425,7 +451,7 @@ public class GarbageManager {
|
||||
"§7已是最后一页"));
|
||||
}
|
||||
|
||||
// 52 格:返回
|
||||
// 52 格:返回主菜单
|
||||
g.setItem(52, mk(Material.ARROW,
|
||||
"§7返回",
|
||||
"§7返回主菜单"));
|
||||
@@ -519,6 +545,38 @@ public class GarbageManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取回前按 id 读原始存档(含原 lore 与 PDC)。
|
||||
* GUI 展示副本的 lore 已被覆盖成
|
||||
* 「ID/点击取出」,取回必须用这个还原,
|
||||
* 否则 §8SN 行与物品说明 lore 会丢。
|
||||
*
|
||||
* @param id items 表主键
|
||||
* @return 原始物品;读不到返回 null
|
||||
*/
|
||||
public ItemStack fetchItem(int id) {
|
||||
try {
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"SELECT item_data, amount FROM items "
|
||||
+ "WHERE id=?");
|
||||
ps.setInt(1, id);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
ItemStack result = null;
|
||||
if (rs.next()) {
|
||||
result = base64ToStack(
|
||||
rs.getString("item_data"));
|
||||
if (result != null) {
|
||||
result.setAmount(rs.getInt("amount"));
|
||||
}
|
||||
}
|
||||
rs.close();
|
||||
ps.close();
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public int getItemCount() {
|
||||
try {
|
||||
Statement st = db.createStatement();
|
||||
|
||||
@@ -194,28 +194,21 @@ public class GiftManager {
|
||||
giveItem(p, Material.WOODEN_AXE);
|
||||
giveItem(p, Material.WOODEN_SHOVEL);
|
||||
} else if (stage == 2) {
|
||||
if (plugin.getEconomy() != null) {
|
||||
double before = plugin.getEconomy()
|
||||
.getBalance(p);
|
||||
plugin.getEconomy()
|
||||
.depositPlayer(p, 500);
|
||||
double after = plugin.getEconomy()
|
||||
.getBalance(p);
|
||||
p.sendMessage("§a§l[礼包] §f游戏币: §e"
|
||||
+ (long) before
|
||||
+ "§f → §a" + (long) after);
|
||||
// 游戏币改为债券
|
||||
BondManager bm = plugin.getBonds();
|
||||
if (bm != null) {
|
||||
int before = bm.getBonds(p.getName());
|
||||
bm.addBonds(p.getName(), 500, "gift_stage2", "", "新手礼包", "阶段2奖励");
|
||||
int after = bm.getBonds(p.getName());
|
||||
p.sendMessage("§a§l[礼包] §f债券: §e" + before + "§f → §a" + after);
|
||||
}
|
||||
} else if (stage == 3) {
|
||||
if (plugin.getEconomy() != null) {
|
||||
double before = plugin.getEconomy()
|
||||
.getBalance(p);
|
||||
plugin.getEconomy()
|
||||
.depositPlayer(p, 1500);
|
||||
double after = plugin.getEconomy()
|
||||
.getBalance(p);
|
||||
p.sendMessage("§a§l[礼包] §f游戏币: §e"
|
||||
+ (long) before
|
||||
+ "§f → §a" + (long) after);
|
||||
BondManager bm = plugin.getBonds();
|
||||
if (bm != null) {
|
||||
int before = bm.getBonds(p.getName());
|
||||
bm.addBonds(p.getName(), 1500, "gift_stage3", "", "新手礼包", "阶段3奖励");
|
||||
int after = bm.getBonds(p.getName());
|
||||
p.sendMessage("§a§l[礼包] §f债券: §e" + before + "§f → §a" + after);
|
||||
}
|
||||
giveItem(p, Material.IRON_PICKAXE);
|
||||
giveItem(p, Material.IRON_AXE);
|
||||
@@ -225,16 +218,12 @@ public class GiftManager {
|
||||
giveItem(p, Material.IRON_LEGGINGS);
|
||||
giveItem(p, Material.IRON_BOOTS);
|
||||
} else if (stage == 4) {
|
||||
if (plugin.getEconomy() != null) {
|
||||
double before = plugin.getEconomy()
|
||||
.getBalance(p);
|
||||
plugin.getEconomy()
|
||||
.depositPlayer(p, 2000);
|
||||
double after = plugin.getEconomy()
|
||||
.getBalance(p);
|
||||
p.sendMessage("§a§l[礼包] §f游戏币: §e"
|
||||
+ (long) before
|
||||
+ "§f → §a" + (long) after);
|
||||
BondManager bm = plugin.getBonds();
|
||||
if (bm != null) {
|
||||
int before = bm.getBonds(p.getName());
|
||||
bm.addBonds(p.getName(), 2000, "gift_stage4", "", "新手礼包", "阶段4奖励");
|
||||
int after = bm.getBonds(p.getName());
|
||||
p.sendMessage("§a§l[礼包] §f债券: §e" + before + "§f → §a" + after);
|
||||
}
|
||||
giveItem(p, Material.DIAMOND_HELMET);
|
||||
giveItem(p, Material.DIAMOND_CHESTPLATE);
|
||||
|
||||
@@ -0,0 +1,770 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.type.Bed;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.BlockBreakEvent;
|
||||
import org.bukkit.event.block.BlockExplodeEvent;
|
||||
import org.bukkit.event.entity.EntityExplodeEvent;
|
||||
import org.bukkit.event.player.PlayerBedEnterEvent;
|
||||
import org.bukkit.event.player.PlayerRespawnEvent;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Home传送点管理器
|
||||
* 支持: sethome, delhome, home, homes
|
||||
* 集成用户组系统: home_limit (0=全局默认, >0=组限制, -1=无限)
|
||||
*/
|
||||
public class HomeManager implements Listener {
|
||||
private final Main plugin;
|
||||
|
||||
// 床位置缓存: player_name → Location (用于自动记录床位置)
|
||||
private final Map<String, Location> bedLocations = new ConcurrentHashMap<>();
|
||||
|
||||
public HomeManager(Main plugin) {
|
||||
this.plugin = plugin;
|
||||
initDatabase();
|
||||
}
|
||||
|
||||
// ==================== 数据结构 ====================
|
||||
|
||||
public static class HomeData {
|
||||
public String name;
|
||||
public String world;
|
||||
public double x, y, z;
|
||||
public float yaw, pitch;
|
||||
public long createdAt;
|
||||
|
||||
public Location toLocation(World world) {
|
||||
return new Location(world, x, y, z, yaw, pitch);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 数据库初始化 ====================
|
||||
|
||||
private void initDatabase() {
|
||||
try {
|
||||
java.sql.Connection db = plugin.getDb().getConnection();
|
||||
if (db == null) {
|
||||
plugin.getLogger().warning("[Home] 无法获取数据库连接");
|
||||
return;
|
||||
}
|
||||
|
||||
java.sql.Statement st = db.createStatement();
|
||||
// homes表已在DatabaseManager中创建,此处确保索引
|
||||
st.execute("CREATE INDEX IF NOT EXISTS idx_homes_player ON homes(player_name)");
|
||||
st.close();
|
||||
|
||||
plugin.getLogger().info("[Home] 数据库初始化完成");
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[Home] 数据库初始化失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 命令处理 ====================
|
||||
|
||||
public boolean handleCommand(Player player, String label, String[] args) {
|
||||
String lowerLabel = label.toLowerCase();
|
||||
|
||||
switch (lowerLabel) {
|
||||
case "sethome":
|
||||
return handleSetHome(player, args);
|
||||
case "delhome":
|
||||
return handleDelHome(player, args);
|
||||
case "home":
|
||||
return handleHome(player, args);
|
||||
case "homes":
|
||||
return handleHomes(player, args);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* /sethome <名称> - 设置家
|
||||
*/
|
||||
private boolean handleSetHome(Player player, String[] args) {
|
||||
if (args.length == 0) {
|
||||
player.sendMessage("§c用法: /sethome <名称>");
|
||||
return true;
|
||||
}
|
||||
|
||||
String homeName = args[0].toLowerCase();
|
||||
|
||||
// 校验名称格式
|
||||
if (!homeName.matches("^[a-zA-Z0-9_]{1,16}$")) {
|
||||
player.sendMessage("§c家名只能包含字母、数字、下划线,长度1-16");
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查家数量限制
|
||||
int maxHomes = getMaxHomes(player);
|
||||
if (maxHomes != -1) {
|
||||
List<HomeData> homes = getHomes(player.getName());
|
||||
if (homes.size() >= maxHomes) {
|
||||
player.sendMessage("§c你已达到家数量上限 (" + maxHomes + "个)");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否已存在同名家
|
||||
if (homeExists(player.getName(), homeName)) {
|
||||
player.sendMessage("§c你已有一个名为 §e" + homeName + " §c的家");
|
||||
return true;
|
||||
}
|
||||
|
||||
// 保存家
|
||||
Location loc = player.getLocation();
|
||||
saveHome(player.getName(), homeName, loc);
|
||||
|
||||
player.sendMessage("§a家 §e" + homeName + " §a已设置!");
|
||||
player.sendMessage("§7使用 §e/home " + homeName + " §7传送回家");
|
||||
player.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* /delhome <名称> - 删除家
|
||||
*/
|
||||
private boolean handleDelHome(Player player, String[] args) {
|
||||
if (args.length == 0) {
|
||||
player.sendMessage("§c用法: /delhome <名称>");
|
||||
return true;
|
||||
}
|
||||
|
||||
String homeName = args[0].toLowerCase();
|
||||
|
||||
if (!homeExists(player.getName(), homeName)) {
|
||||
player.sendMessage("§c你没有名为 §e" + homeName + " §c的家");
|
||||
return true;
|
||||
}
|
||||
|
||||
deleteHome(player.getName(), homeName);
|
||||
player.sendMessage("§a家 §e" + homeName + " §a已删除!");
|
||||
player.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* /home [名称] - 传送到家
|
||||
*/
|
||||
private boolean handleHome(Player player, String[] args) {
|
||||
if (args.length == 0) {
|
||||
// 无参数: 传送到第一个家
|
||||
List<HomeData> homes = getHomes(player.getName());
|
||||
if (homes.isEmpty()) {
|
||||
player.sendMessage("§c你还没有设置任何家");
|
||||
player.sendMessage("§7使用 §e/sethome <名称> §7设置一个家");
|
||||
player.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
|
||||
return true;
|
||||
}
|
||||
teleportToHome(player, homes.get(0));
|
||||
return true;
|
||||
}
|
||||
|
||||
String homeName = args[0].toLowerCase();
|
||||
HomeData home = getHome(player.getName(), homeName);
|
||||
|
||||
if (home == null) {
|
||||
player.sendMessage("§c你没有名为 §e" + homeName + " §c的家");
|
||||
return true;
|
||||
}
|
||||
|
||||
teleportToHome(player, home);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* /homes - 查看所有家
|
||||
*/
|
||||
private boolean handleHomes(Player player, String[] args) {
|
||||
List<HomeData> homes = getHomes(player.getName());
|
||||
|
||||
if (homes.isEmpty()) {
|
||||
player.sendMessage("§e你还没有设置任何家");
|
||||
player.sendMessage("§7使用 §e/sethome <名称> §7设置一个家");
|
||||
return true;
|
||||
}
|
||||
|
||||
int maxHomes = getMaxHomes(player);
|
||||
player.sendMessage("§6§l你的家 §7(" + homes.size() + (maxHomes == -1 ? "/" + "∞" : "/" + maxHomes) + ")");
|
||||
player.sendMessage("§7────────────────────");
|
||||
|
||||
for (HomeData home : homes) {
|
||||
String worldName = home.world != null ? home.world : "未知";
|
||||
player.sendMessage("§e • " + home.name + " §7[" + worldName + "]");
|
||||
}
|
||||
|
||||
player.sendMessage("§7────────────────────");
|
||||
player.sendMessage("§7使用 §e/home <名称> §7传送");
|
||||
player.sendMessage("§7使用 §e/sethome <名称> §7设置新家");
|
||||
player.sendMessage("§7使用 §e/delhome <名称> §7删除家");
|
||||
player.sendMessage("§7—————————————————————");
|
||||
player.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== 床事件监听 ====================
|
||||
|
||||
private static final org.bukkit.block.BlockFace[] HORIZ_FACES = {
|
||||
org.bukkit.block.BlockFace.NORTH, org.bukkit.block.BlockFace.SOUTH,
|
||||
org.bukkit.block.BlockFace.EAST, org.bukkit.block.BlockFace.WEST };
|
||||
|
||||
/** 危险方块:落点优先级判断用 */
|
||||
private static final Set<Material> DANGEROUS = EnumSet.of(
|
||||
Material.LAVA, Material.FIRE, Material.SOUL_FIRE, Material.MAGMA_BLOCK,
|
||||
Material.CACTUS, Material.CAMPFIRE, Material.SOUL_CAMPFIRE,
|
||||
Material.SWEET_BERRY_BUSH, Material.WITHER_ROSE, Material.POWDER_SNOW,
|
||||
Material.POINTED_DRIPSTONE);
|
||||
|
||||
/** 是否是床方块 */
|
||||
private static boolean isBed(Block b) {
|
||||
return b.getBlockData() instanceof Bed;
|
||||
}
|
||||
|
||||
/** 取床头方块(配对缺失时返回原方块);非床返回 null */
|
||||
private static Block getBedHead(Block b) {
|
||||
if (!isBed(b)) return null;
|
||||
if (((Bed) b.getBlockData()).getPart() == Bed.Part.HEAD) return b;
|
||||
Block head = horizontalNeighborWithPart(b, Bed.Part.HEAD);
|
||||
return head != null ? head : b;
|
||||
}
|
||||
|
||||
private static Block horizontalNeighborWithPart(Block b, Bed.Part part) {
|
||||
for (org.bukkit.block.BlockFace f : HORIZ_FACES) {
|
||||
Block n = b.getRelative(f);
|
||||
if (isBed(n) && ((Bed) n.getBlockData()).getPart() == part) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 床的两半 [head, foot(可能为null)];非床返回 null */
|
||||
private static Block[] getBedParts(Block b) {
|
||||
if (!isBed(b)) return null;
|
||||
if (((Bed) b.getBlockData()).getPart() == Bed.Part.HEAD) {
|
||||
return new Block[]{ b, horizontalNeighborWithPart(b, Bed.Part.FOOT) };
|
||||
}
|
||||
Block head = horizontalNeighborWithPart(b, Bed.Part.HEAD);
|
||||
return new Block[]{ head != null ? head : b, b };
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家与床交互(上床/白天点床)→ bed 传送点始终更新为该床的精确坐标
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerBedEnter(PlayerBedEnterEvent event) {
|
||||
// 下界/末地床爆炸、怪物附近、距离过远等未真正交互的情况不记录
|
||||
PlayerBedEnterEvent.BedEnterResult r = event.getBedEnterResult();
|
||||
if (r != PlayerBedEnterEvent.BedEnterResult.OK
|
||||
&& r != PlayerBedEnterEvent.BedEnterResult.NOT_POSSIBLE_NOW
|
||||
&& r != PlayerBedEnterEvent.BedEnterResult.OBSTRUCTED) {
|
||||
return;
|
||||
}
|
||||
Player player = event.getPlayer();
|
||||
Block head = getBedHead(event.getBed());
|
||||
if (head == null) return;
|
||||
Location bedLoc = head.getLocation();
|
||||
|
||||
bedLocations.put(player.getName(), bedLoc);
|
||||
|
||||
HomeData existing = getHome(player.getName(), "bed");
|
||||
if (existing == null) {
|
||||
saveHome(player.getName(), "bed", bedLoc);
|
||||
player.sendMessage("§a床位置已自动保存为 §e/home bed");
|
||||
} else if (!sameBlock(existing, bedLoc)) {
|
||||
// 与其它床交互 → 更新传送点坐标
|
||||
updateHome(player.getName(), "bed", bedLoc);
|
||||
player.sendMessage("§a床传送点已更新为该床的位置");
|
||||
player.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家复活 → 抓取重生点附近的精确床坐标并更新 bed 传送点
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerRespawn(PlayerRespawnEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
Location respawnLoc = event.getRespawnLocation();
|
||||
if (respawnLoc == null || respawnLoc.getWorld() == null) return;
|
||||
|
||||
Block bed = findNearbyBed(respawnLoc);
|
||||
if (bed == null) return; // 世界出生点复活等,不碰传送点
|
||||
Block head = getBedHead(bed);
|
||||
Location exact = (head != null ? head : bed).getLocation();
|
||||
|
||||
HomeData existing = getHome(player.getName(), "bed");
|
||||
if (existing == null) {
|
||||
saveHome(player.getName(), "bed", exact);
|
||||
} else if (!sameBlock(existing, exact)) {
|
||||
updateHome(player.getName(), "bed", exact);
|
||||
}
|
||||
bedLocations.put(player.getName(), exact);
|
||||
}
|
||||
|
||||
/** 在重生点附近(水平±3、垂直-1~+2)寻找最近的床方块 */
|
||||
private Block findNearbyBed(Location loc) {
|
||||
World w = loc.getWorld();
|
||||
if (w == null) return null;
|
||||
int bx = loc.getBlockX(), by = loc.getBlockY(), bz = loc.getBlockZ();
|
||||
Block best = null;
|
||||
double bestD = Double.MAX_VALUE;
|
||||
for (int dx = -3; dx <= 3; dx++)
|
||||
for (int dy = -1; dy <= 2; dy++)
|
||||
for (int dz = -3; dz <= 3; dz++) {
|
||||
Block b = w.getBlockAt(bx + dx, by + dy, bz + dz);
|
||||
if (!isBed(b)) continue;
|
||||
double d = (double) dx * dx + (double) dy * dy + (double) dz * dz;
|
||||
if (d < bestD) { bestD = d; best = b; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private boolean sameBlock(HomeData home, Location loc) {
|
||||
return home.world != null && loc.getWorld() != null
|
||||
&& home.world.equals(loc.getWorld().getName())
|
||||
&& Math.floor(home.x) == loc.getBlockX()
|
||||
&& Math.floor(home.y) == loc.getBlockY()
|
||||
&& Math.floor(home.z) == loc.getBlockZ();
|
||||
}
|
||||
|
||||
/**
|
||||
* 床没了 → 自动删除对应玩家的 bed 传送点(破坏/爆炸均触发)
|
||||
*/
|
||||
private void removeBedHomesFor(Block bedPart) {
|
||||
Block[] parts = getBedParts(bedPart);
|
||||
if (parts == null) return;
|
||||
Set<String> owners = new HashSet<>();
|
||||
for (Block part : parts) {
|
||||
if (part == null) continue;
|
||||
owners.addAll(queryBedHomeOwners(part));
|
||||
}
|
||||
for (String owner : owners) {
|
||||
deleteHome(owner, "bed");
|
||||
bedLocations.remove(owner);
|
||||
Player p = Bukkit.getPlayerExact(owner);
|
||||
if (p != null && p.isOnline()) {
|
||||
p.sendMessage("§c你的床已被破坏,/home bed 传送点已自动删除");
|
||||
p.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询 bed 传送点坐标与指定床方块重合的玩家列表 */
|
||||
private Set<String> queryBedHomeOwners(Block bedBlock) {
|
||||
Set<String> owners = new HashSet<>();
|
||||
try {
|
||||
java.sql.Connection db = plugin.getDb().getConnection();
|
||||
if (db == null) return owners;
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"SELECT player_name FROM homes WHERE home_name='bed' AND world=? "
|
||||
+ "AND ABS(x-?)<0.01 AND ABS(y-?)<0.01 AND ABS(z-?)<0.01");
|
||||
ps.setString(1, bedBlock.getWorld().getName());
|
||||
ps.setDouble(2, bedBlock.getX());
|
||||
ps.setDouble(3, bedBlock.getY());
|
||||
ps.setDouble(4, bedBlock.getZ());
|
||||
ResultSet rs = ps.executeQuery();
|
||||
while (rs.next()) owners.add(rs.getString(1));
|
||||
rs.close();
|
||||
ps.close();
|
||||
} catch (SQLException e) {
|
||||
plugin.getLogger().warning("[Home] 查询bed传送点失败: " + e.getMessage());
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onBlockBreak(BlockBreakEvent event) {
|
||||
if (isBed(event.getBlock())) removeBedHomesFor(event.getBlock());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onEntityExplode(EntityExplodeEvent event) {
|
||||
for (Block b : event.blockList()) {
|
||||
if (isBed(b)) removeBedHomesFor(b);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onBlockExplode(BlockExplodeEvent event) {
|
||||
for (Block b : event.blockList()) {
|
||||
if (isBed(b)) removeBedHomesFor(b);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 家数量限制 ====================
|
||||
|
||||
/**
|
||||
* 获取玩家最大家数量
|
||||
* @return -1=无限, 0=使用全局默认, >0=用户组独立限制
|
||||
*/
|
||||
private int getMaxHomes(Player player) {
|
||||
UserGroupManager userGroupMgr = plugin.getUserGroup();
|
||||
if (userGroupMgr == null) {
|
||||
return getGlobalHomeLimit();
|
||||
}
|
||||
|
||||
// 获取玩家最高优先级用户组
|
||||
UserGroupManager.UserGroupConfig highestGroup = userGroupMgr.getHighestGroup(player.getName());
|
||||
if (highestGroup == null) {
|
||||
return getGlobalHomeLimit();
|
||||
}
|
||||
|
||||
// 获取用户组的home_limit配置
|
||||
int groupLimit = userGroupMgr.getHomeLimit(highestGroup.name);
|
||||
|
||||
if (groupLimit == -1) {
|
||||
// 无限
|
||||
return -1;
|
||||
} else if (groupLimit == 0) {
|
||||
// 跟随全局
|
||||
return getGlobalHomeLimit();
|
||||
} else {
|
||||
// 独立限制
|
||||
return groupLimit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全局家数量限制
|
||||
* 读取area_config中的max_home_per_player配置(管理员可在Web后台调整)
|
||||
* 解析失败或值<=0时回退到默认5
|
||||
*/
|
||||
private int getGlobalHomeLimit() {
|
||||
try {
|
||||
if (plugin.areaProtection != null) {
|
||||
int limit = plugin.areaProtection.getGlobalMaxHomePerPlayer();
|
||||
if (limit > 0) {
|
||||
return limit;
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
// 默认5个家
|
||||
return 5;
|
||||
}
|
||||
|
||||
// ==================== 数据库操作 ====================
|
||||
|
||||
private boolean homeExists(String playerName, String homeName) {
|
||||
try {
|
||||
java.sql.Connection db = plugin.getDb().getConnection();
|
||||
if (db == null) return false;
|
||||
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"SELECT COUNT(*) FROM homes WHERE player_name=? AND home_name=?");
|
||||
ps.setString(1, playerName);
|
||||
ps.setString(2, homeName);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
boolean exists = rs.next() && rs.getInt(1) > 0;
|
||||
rs.close();
|
||||
ps.close();
|
||||
return exists;
|
||||
} catch (SQLException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private List<HomeData> getHomes(String playerName) {
|
||||
List<HomeData> homes = new ArrayList<>();
|
||||
try {
|
||||
java.sql.Connection db = plugin.getDb().getConnection();
|
||||
if (db == null) return homes;
|
||||
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"SELECT * FROM homes WHERE player_name=? ORDER BY created_at");
|
||||
ps.setString(1, playerName);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
|
||||
while (rs.next()) {
|
||||
HomeData home = new HomeData();
|
||||
home.name = rs.getString("home_name");
|
||||
home.world = rs.getString("world");
|
||||
home.x = rs.getDouble("x");
|
||||
home.y = rs.getDouble("y");
|
||||
home.z = rs.getDouble("z");
|
||||
home.yaw = rs.getFloat("yaw");
|
||||
home.pitch = rs.getFloat("pitch");
|
||||
home.createdAt = rs.getLong("created_at");
|
||||
homes.add(home);
|
||||
}
|
||||
|
||||
rs.close();
|
||||
ps.close();
|
||||
} catch (SQLException e) {
|
||||
plugin.getLogger().warning("[Home] 获取家列表失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
return homes;
|
||||
}
|
||||
|
||||
private HomeData getHome(String playerName, String homeName) {
|
||||
List<HomeData> homes = getHomes(playerName);
|
||||
for (HomeData home : homes) {
|
||||
if (home.name.equalsIgnoreCase(homeName)) {
|
||||
return home;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void saveHome(String playerName, String homeName, Location loc) {
|
||||
try {
|
||||
java.sql.Connection db = plugin.getDb().getConnection();
|
||||
if (db == null) return;
|
||||
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"INSERT OR REPLACE INTO homes (player_name, home_name, world, x, y, z, yaw, pitch, created_at) "
|
||||
+ "VALUES (?,?,?,?,?,?,?,?,?)");
|
||||
ps.setString(1, playerName);
|
||||
ps.setString(2, homeName);
|
||||
ps.setString(3, loc.getWorld() != null ? loc.getWorld().getName() : "world");
|
||||
ps.setDouble(4, loc.getX());
|
||||
ps.setDouble(5, loc.getY());
|
||||
ps.setDouble(6, loc.getZ());
|
||||
ps.setFloat(7, loc.getYaw());
|
||||
ps.setFloat(8, loc.getPitch());
|
||||
ps.setLong(9, System.currentTimeMillis());
|
||||
ps.executeUpdate();
|
||||
ps.close();
|
||||
} catch (SQLException e) {
|
||||
plugin.getLogger().warning("[Home] 保存家失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void updateHome(String playerName, String homeName, Location loc) {
|
||||
saveHome(playerName, homeName, loc); // INSERT OR REPLACE 自动更新
|
||||
}
|
||||
|
||||
private void deleteHome(String playerName, String homeName) {
|
||||
try {
|
||||
java.sql.Connection db = plugin.getDb().getConnection();
|
||||
if (db == null) return;
|
||||
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"DELETE FROM homes WHERE player_name=? AND home_name=?");
|
||||
ps.setString(1, playerName);
|
||||
ps.setString(2, homeName);
|
||||
ps.executeUpdate();
|
||||
ps.close();
|
||||
} catch (SQLException e) {
|
||||
plugin.getLogger().warning("[Home] 删除家失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 传送玩家到家
|
||||
*/
|
||||
private void teleportToHome(Player player, HomeData home) {
|
||||
// bed 家走原版复活落点算法(自写,不模拟死亡、无死亡提示)
|
||||
if ("bed".equalsIgnoreCase(home.name)) {
|
||||
teleportToBedHome(player, home);
|
||||
return;
|
||||
}
|
||||
World world = Bukkit.getWorld(home.world);
|
||||
if (world == null) {
|
||||
player.sendMessage("§c世界 §e" + home.world + " §c不存在");
|
||||
return;
|
||||
}
|
||||
|
||||
Location loc = home.toLocation(world);
|
||||
|
||||
// 安全检查: 确保位置安全
|
||||
if (loc.getBlock().getType().isSolid()) {
|
||||
// 向上寻找安全位置
|
||||
for (int y = (int) loc.getY(); y < 256; y++) {
|
||||
Location safeLoc = new Location(world, loc.getX(), y + 1, loc.getZ());
|
||||
if (!safeLoc.getBlock().getType().isSolid()) {
|
||||
loc = safeLoc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
player.teleport(loc);
|
||||
player.sendMessage("§a已传送到家 §e" + home.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回床:先校验床还在(没了自动删传送点),再用自写算法模拟原版复活落点
|
||||
* (床周合法站位 + 视线水平看向床头),直接传送,不模拟死亡。
|
||||
*/
|
||||
private void teleportToBedHome(Player player, HomeData home) {
|
||||
World world = Bukkit.getWorld(home.world);
|
||||
if (world == null) {
|
||||
player.sendMessage("§c世界 §e" + home.world + " §c不存在");
|
||||
return;
|
||||
}
|
||||
|
||||
Location saved = home.toLocation(world);
|
||||
Block savedBlock = world.getBlockAt(saved);
|
||||
Block head = getBedHead(savedBlock);
|
||||
// 保存坐标上没有床 → 床已没,自动删传送点
|
||||
if (head == null) {
|
||||
deleteHome(player.getName(), "bed");
|
||||
bedLocations.remove(player.getName());
|
||||
player.sendMessage("§c你的床已不存在,/home bed 传送点已自动删除");
|
||||
return;
|
||||
}
|
||||
|
||||
Location dest = findVanillaRespawnSpot(world, head);
|
||||
if (dest == null) {
|
||||
player.sendMessage("§c床周围被阻挡,暂时无法回到床边");
|
||||
return;
|
||||
}
|
||||
|
||||
// 若坐标与记录不一致(床被重摆),顺手修正传送点
|
||||
if (!sameBlock(home, head.getLocation())) {
|
||||
updateHome(player.getName(), "bed", head.getLocation());
|
||||
bedLocations.put(player.getName(), head.getLocation());
|
||||
}
|
||||
|
||||
player.teleport(dest);
|
||||
player.sendMessage("§a已回到床边");
|
||||
}
|
||||
|
||||
/**
|
||||
* 自写算法模拟原版床复活落点:
|
||||
* 1. 候选 = 床头8邻(原版7块优先区,排除床自身占位)→ 床尾8邻兜底;
|
||||
* 排序遵循原版"西北(低X低Z)最先,其次向南(+Z)再向东(+X)"。
|
||||
* 2. 合法性 = 落点方块及上方一格无碰撞 + 下方一格有碰撞(可站立),
|
||||
* 优先无危险方块的落点(火/岩浆/仙人掌等降级为次选)。
|
||||
* 3. 落地朝向:视线水平看向床头。
|
||||
* 全部被阻挡时回退到床位置向上找安全格。
|
||||
*/
|
||||
private Location findVanillaRespawnSpot(World world, Block head) {
|
||||
Block[] parts = getBedParts(head);
|
||||
Block foot = (parts != null && parts.length > 1) ? parts[1] : null;
|
||||
int bedY = head.getY();
|
||||
|
||||
// 收集候选:床头邻格(排除床两半自身),再床尾邻格
|
||||
List<Block> candidates = new ArrayList<>();
|
||||
collectRing(head, foot, candidates);
|
||||
if (foot != null) collectRing(foot, head, candidates);
|
||||
|
||||
// 排序:先床头环(保持顺序),床尾环在后;环内按 X→Z 升序(西北优先、南先于东)
|
||||
candidates.sort(Comparator
|
||||
.comparingInt((Block b) -> b.getY() == bedY ? 0 : 1)
|
||||
.thenComparingInt(Block::getX)
|
||||
.thenComparingInt(Block::getZ));
|
||||
|
||||
// 两轮:先无危险落点,再放宽(原版同样优先安全位)
|
||||
for (int pass = 0; pass < 2; pass++) {
|
||||
for (Block c : candidates) {
|
||||
if (!isStandable(world, c.getX(), c.getY(), c.getZ())) continue;
|
||||
boolean dangerous = isDangerous(world, c.getX(), c.getY(), c.getZ())
|
||||
|| DANGEROUS.contains(world.getBlockAt(c.getX(), c.getY() - 1, c.getZ()).getType());
|
||||
if (pass == 0 && dangerous) continue;
|
||||
float yaw = faceBlockYaw(c, head);
|
||||
return new Location(world, c.getX() + 0.5, c.getY(), c.getZ() + 0.5, yaw, 0f);
|
||||
}
|
||||
}
|
||||
|
||||
// 全被阻挡:回退到床本身向上找安全格
|
||||
Location loc = new Location(world, head.getX() + 0.5, head.getY(), head.getZ() + 0.5);
|
||||
for (int y = head.getY(); y < world.getMaxHeight(); y++) {
|
||||
if (isStandable(world, head.getX(), y, head.getZ())) {
|
||||
float yaw = faceBlockYaw(head.getX(), y, head.getZ(), head);
|
||||
return new Location(world, head.getX() + 0.5, y, head.getZ() + 0.5, yaw, 0f);
|
||||
}
|
||||
loc.setY(y + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 收集 center 的8个水平邻格(排除 exclude 床块及其配对半块) */
|
||||
private void collectRing(Block center, Block exclude, List<Block> out) {
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
for (int dz = -1; dz <= 1; dz++) {
|
||||
if (dx == 0 && dz == 0) continue;
|
||||
Block n = center.getRelative(dx, 0, dz);
|
||||
if (isBed(n)) continue;
|
||||
if (exclude != null && isBedPartOf(n, exclude)) continue;
|
||||
out.add(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBedPartOf(Block b, Block anyBedPart) {
|
||||
Block[] parts = getBedParts(anyBedPart);
|
||||
if (parts == null) return false;
|
||||
for (Block p : parts) {
|
||||
if (p != null && p.equals(b)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 站位合法性:落点与上方一格无碰撞,脚下有支撑 */
|
||||
private boolean isStandable(World world, int x, int y, int z) {
|
||||
if (y - 1 < world.getMinHeight()) return false;
|
||||
Block feet = world.getBlockAt(x, y, z);
|
||||
Block headSpace = world.getBlockAt(x, y + 1, z);
|
||||
Block ground = world.getBlockAt(x, y - 1, z);
|
||||
if (feet.getType() == Material.LAVA || feet.getType() == Material.WATER) return false;
|
||||
if (!feet.isPassable() || !headSpace.isPassable()) return false;
|
||||
return !ground.isPassable() || ground.getType() == Material.LADDER
|
||||
|| ground.getType() == Material.VINE;
|
||||
}
|
||||
|
||||
private boolean isDangerous(World world, int x, int y, int z) {
|
||||
return DANGEROUS.contains(world.getBlockAt(x, y, z).getType());
|
||||
}
|
||||
|
||||
private float faceBlockYaw(Block from, Block target) {
|
||||
return faceBlockYaw(from.getX(), from.getY(), from.getZ(), target);
|
||||
}
|
||||
|
||||
private float faceBlockYaw(int x, int y, int z, Block target) {
|
||||
double dx = (target.getX() + 0.5) - (x + 0.5);
|
||||
double dz = (target.getZ() + 0.5) - (z + 0.5);
|
||||
return (float) Math.toDegrees(Math.atan2(-dx, dz));
|
||||
}
|
||||
|
||||
// ==================== Tab补全 ====================
|
||||
|
||||
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (!(sender instanceof Player)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Player player = (Player) sender;
|
||||
String cmdName = command.getName().toLowerCase();
|
||||
|
||||
if (cmdName.equals("home") || cmdName.equals("delhome")) {
|
||||
if (args.length == 1) {
|
||||
List<String> homes = new ArrayList<>();
|
||||
for (HomeData home : getHomes(player.getName())) {
|
||||
if (home.name.toLowerCase().startsWith(args[0].toLowerCase())) {
|
||||
homes.add(home.name);
|
||||
}
|
||||
}
|
||||
return homes;
|
||||
}
|
||||
} else if (cmdName.equals("sethome") || cmdName.equals("homes")) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// ==================== 清理 ====================
|
||||
|
||||
public void shutdown() {
|
||||
bedLocations.clear();
|
||||
}
|
||||
}
|
||||
@@ -16,38 +16,38 @@ public class IPGroupManager {
|
||||
this.maxAccounts = maxAccounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查该IP是否还能注册新账号
|
||||
*/
|
||||
public boolean canRegister(String ip) {
|
||||
if (maxAccounts <= 0) return true;
|
||||
if (ip == null || ip.isEmpty()) return true;
|
||||
return queryAccountsByIP(ip).size() < maxAccounts;
|
||||
int count = getAccountCount(ip);
|
||||
boolean can = count < maxAccounts;
|
||||
plugin.getLogger().info(
|
||||
"[IP限制] IP=" + ip
|
||||
+ " 已注册=" + count
|
||||
+ " 上限=" + maxAccounts
|
||||
+ " 允许=" + can);
|
||||
return can;
|
||||
}
|
||||
|
||||
public int getMaxAccounts() {
|
||||
return maxAccounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取该IP下已注册的账号数量
|
||||
*/
|
||||
public int getAccountCount(String ip) {
|
||||
if (ip == null || ip.isEmpty()) return 0;
|
||||
return queryAccountsByIP(ip).size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取该IP下所有账号名
|
||||
*/
|
||||
public List<String> getAccounts(String ip) {
|
||||
return queryAccountsByIP(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接通过数据库连接查询指定IP下的所有账号
|
||||
*/
|
||||
private List<String> queryAccountsByIP(String ip) {
|
||||
List<String> accounts = new ArrayList<>();
|
||||
Connection conn = plugin.getDb().getDb();
|
||||
if (conn == null) return accounts;
|
||||
try {
|
||||
// 主查询:ip_address 列
|
||||
PreparedStatement ps = conn.prepareStatement(
|
||||
"SELECT player_name FROM users "
|
||||
+ "WHERE ip_address = ?");
|
||||
@@ -59,6 +59,53 @@ public class IPGroupManager {
|
||||
}
|
||||
rs.close();
|
||||
ps.close();
|
||||
|
||||
// 兜底:如果主列没数据,查 last_login_ip
|
||||
if (accounts.isEmpty()) {
|
||||
PreparedStatement ps2 = conn.prepareStatement(
|
||||
"SELECT player_name FROM users "
|
||||
+ "WHERE last_login_ip = ? "
|
||||
+ "AND (ip_address IS NULL "
|
||||
+ "OR ip_address = '' "
|
||||
+ "OR ip_address != ?)");
|
||||
ps2.setString(1, ip);
|
||||
ps2.setString(2, ip);
|
||||
ResultSet rs2 = ps2.executeQuery();
|
||||
while (rs2.next()) {
|
||||
String name = rs2.getString("player_name");
|
||||
if (!accounts.contains(name))
|
||||
accounts.add(name);
|
||||
}
|
||||
rs2.close();
|
||||
ps2.close();
|
||||
}
|
||||
|
||||
// 兜底2:查 register_ip
|
||||
if (accounts.isEmpty()) {
|
||||
PreparedStatement ps3 = conn.prepareStatement(
|
||||
"SELECT player_name FROM users "
|
||||
+ "WHERE register_ip = ? "
|
||||
+ "AND (ip_address IS NULL "
|
||||
+ "OR ip_address = '') "
|
||||
+ "AND (last_login_ip IS NULL "
|
||||
+ "OR last_login_ip = '' "
|
||||
+ "OR last_login_ip != ?)");
|
||||
ps3.setString(1, ip);
|
||||
ps3.setString(2, ip);
|
||||
ResultSet rs3 = ps3.executeQuery();
|
||||
while (rs3.next()) {
|
||||
String name = rs3.getString("player_name");
|
||||
if (!accounts.contains(name))
|
||||
accounts.add(name);
|
||||
}
|
||||
rs3.close();
|
||||
ps3.close();
|
||||
}
|
||||
|
||||
plugin.getLogger().info(
|
||||
"[IP限制] 查询IP=" + ip
|
||||
+ " 结果数=" + accounts.size()
|
||||
+ " 账号=" + accounts);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 背包滚动自动备份。
|
||||
* <p>
|
||||
* ★ 功能:
|
||||
* 1. 由 Main 的 15 分钟定时任务驱动,遍历所有在线玩家。
|
||||
* 2. 在 PVP 竞技场 / 练习场(测试场)的玩家不备份 —— 避免竞技场装备覆盖真实背包。
|
||||
* 3. 序列化 主背包 / 护甲 / 副手(extra) / 等级 / 经验,写入 inv_backup_auto 表。
|
||||
* 4. 每玩家保留最近 3 份(滚动覆盖),由 DatabaseManager.saveAutoBackup 负责清理。
|
||||
* <p>
|
||||
* 与登录时的 saveInventoryBackup 共用同一套存储语义(level=玩家等级,
|
||||
* experience=当前等级进度 p.getExp()),便于后续统一还原。
|
||||
*/
|
||||
public class InventoryAutoBackup {
|
||||
|
||||
private final Main plugin;
|
||||
|
||||
/** 备份周期:15 分钟(单位 tick) */
|
||||
public static final long INTERVAL_TICKS = 15L * 60L * 20L;
|
||||
|
||||
public InventoryAutoBackup(Main plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
/** 对所有在线玩家执行一次滚动备份 */
|
||||
public void runBackup() {
|
||||
for (Player p : Bukkit.getOnlinePlayers()) {
|
||||
try {
|
||||
backupPlayer(p);
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning(
|
||||
"[自动备份] 玩家 " + p.getName()
|
||||
+ " 备份失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void backupPlayer(Player p) {
|
||||
String name = p.getName();
|
||||
|
||||
// ★ 跳过 PVP 竞技场:竞技场内不备份,避免 PVP 装备覆盖真实背包
|
||||
if (plugin.getPVPArenaManager().isInPVPArena(name)) {
|
||||
return;
|
||||
}
|
||||
// ★ 跳过练习场 / 测试世界
|
||||
if (plugin.getPVPTestManager().isInTestWorld(p)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ItemStack[] contents = p.getInventory().getContents();
|
||||
ItemStack[] armor = p.getInventory().getArmorContents();
|
||||
ItemStack[] extra = p.getInventory().getExtraContents();
|
||||
|
||||
String contentsB64, armorB64, extraB64;
|
||||
try {
|
||||
contentsB64 = InventorySerializer.toBase64(contents);
|
||||
armorB64 = InventorySerializer.toBase64(armor);
|
||||
extraB64 = InventorySerializer.toBase64(extra);
|
||||
} catch (IOException e) {
|
||||
plugin.getLogger().warning(
|
||||
"[自动备份] 序列化失败 (" + name + "): "
|
||||
+ e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
final int level = p.getLevel();
|
||||
final double experience = p.getExp(); // 当前等级进度 0.0~1.0
|
||||
// ★ 读背包/等级必须主线程;落库是阻塞 DB 写 -> 投递异步执行
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
try {
|
||||
plugin.getDb().saveAutoBackup(
|
||||
name, contentsB64, armorB64, extraB64,
|
||||
level, experience);
|
||||
plugin.getLogger().info("[自动备份] " + name + " 背包已备份");
|
||||
} catch (Throwable t) {
|
||||
plugin.getLogger().warning("[自动备份] 落库失败 (" + name + "): " + t.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerToggleSneakEvent;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public class IronBlockElevator implements Listener {
|
||||
|
||||
private static final int MAX_RANGE = 64;
|
||||
private static final int MIN_RANGE = 5; // 屏蔽±5格以内的铁块
|
||||
private final Set<UUID> cooldown = new HashSet<>();
|
||||
|
||||
/**
|
||||
* 检查指定位置脚下是否是铁块
|
||||
*/
|
||||
private boolean isIronBlockBelow(Location loc) {
|
||||
Block block = loc.getWorld().getBlockAt(
|
||||
loc.getBlockX(), loc.getBlockY() - 1,
|
||||
loc.getBlockZ());
|
||||
return block.getType() == Material.IRON_BLOCK;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从起始位置向上或向下搜索铁块并传送
|
||||
* @param dir 1=向上 -1=向下
|
||||
*/
|
||||
private boolean tryTeleport(Player p, int dir) {
|
||||
Location loc = p.getLocation();
|
||||
int baseY = loc.getBlockY();
|
||||
|
||||
for (int i = MIN_RANGE; i < MAX_RANGE; i++) {
|
||||
int y = baseY + i * dir;
|
||||
Block block = loc.getWorld().getBlockAt(
|
||||
loc.getBlockX(), y, loc.getBlockZ());
|
||||
if (block.getType() == Material.IRON_BLOCK) {
|
||||
cooldown.add(p.getUniqueId());
|
||||
Location dest = new Location(
|
||||
loc.getWorld(),
|
||||
loc.getX(),
|
||||
block.getY() + 1,
|
||||
loc.getZ());
|
||||
p.teleport(dest);
|
||||
org.bukkit.Bukkit.getScheduler()
|
||||
.runTaskLater(
|
||||
org.bukkit.Bukkit.getPluginManager()
|
||||
.getPlugin("Sdf1_login"),
|
||||
() -> cooldown.remove(
|
||||
p.getUniqueId()),
|
||||
15L);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ===== 下蹲 → 向下传送 =====
|
||||
@EventHandler
|
||||
public void onSneak(PlayerToggleSneakEvent e) {
|
||||
if (!e.isSneaking()) return;
|
||||
Player p = e.getPlayer();
|
||||
if (cooldown.contains(p.getUniqueId())) return;
|
||||
// 必须站在铁块上
|
||||
if (!isIronBlockBelow(p.getLocation())) return;
|
||||
tryTeleport(p, -1);
|
||||
}
|
||||
|
||||
// ===== 跳跃 → 向上传送 =====
|
||||
@EventHandler
|
||||
public void onMove(PlayerMoveEvent e) {
|
||||
Player p = e.getPlayer();
|
||||
if (cooldown.contains(p.getUniqueId())) return;
|
||||
if (p.isSneaking()) return;
|
||||
|
||||
Location from = e.getFrom();
|
||||
Location to = e.getTo();
|
||||
if (to == null) return;
|
||||
if (to.getY() <= from.getY()) return;
|
||||
if (p.getVelocity().getY() <= 0) return;
|
||||
|
||||
// 关键:检查跳跃前的位置(from)脚下是否是铁块
|
||||
// 跳跃后玩家已离开铁块上方,检查 to 会失败
|
||||
if (!isIronBlockBelow(from)) return;
|
||||
|
||||
tryTeleport(p, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import Sdf1_login.AreaProtection.AreaConfig;
|
||||
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.inventory.InventoryCloseEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.event.HoverEvent;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 领地方块/容器记录系统 —— 回声碎片(echo_shard)工具。
|
||||
* <p>
|
||||
* ★ 功能:
|
||||
* 1. 领地内方块破坏/放置 → 自动写入 land_block_log
|
||||
* 2. 领地内容器打开/拿取 → 自动写入 land_container_log
|
||||
* 3. 玩家手持回声碎片右键/左键方块或容器 → 聊天栏打印该坐标的历史记录
|
||||
* 4. /landrec give [玩家] → 发放回声碎片(由插件/管理员发放)
|
||||
* <p>
|
||||
* 记录保留 7 天滚动(由 Main 的每日清理定时器调用 prune)。
|
||||
*/
|
||||
public class LandRecordManager implements Listener {
|
||||
|
||||
private final Main plugin;
|
||||
|
||||
/** 回声碎片物品的 NBT 标识 key */
|
||||
public static final String ECHO_SHARD_KEY = "sdf1_echo_shard";
|
||||
/** 回声碎片物品材质(1.19+ 存在) */
|
||||
public static final Material ECHO_SHARD_MATERIAL = Material.ECHO_SHARD;
|
||||
|
||||
/** 玩家最后一次查询的坐标缓存,翻页导航时复用,避免 getTargetBlockExact 不准 */
|
||||
private final Map<UUID, Location> lastQueryLocations = new ConcurrentHashMap<>();
|
||||
|
||||
/** 记录查询默认条数 */
|
||||
private static final int QUERY_LIMIT = 20;
|
||||
/** 记录保留天数 */
|
||||
public static final int RETENTION_DAYS = 7;
|
||||
|
||||
private final SimpleDateFormat sdf =
|
||||
new SimpleDateFormat("MM-dd HH:mm");
|
||||
|
||||
/**
|
||||
* 容器打开时的快照:用于关闭时计算"拿了什么"。
|
||||
* key = 玩家名(MC 同一时刻只能打开一个容器 GUI,故按玩家唯一即可)。
|
||||
*/
|
||||
private final Map<String, ContainerSnapshot> openSnapshots =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
static class ContainerSnapshot {
|
||||
String landName;
|
||||
int landId;
|
||||
String world;
|
||||
int x, y, z;
|
||||
String containerType;
|
||||
Map<String, Integer> before = new HashMap<>();
|
||||
}
|
||||
|
||||
public LandRecordManager(Main plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
// ==================== 回声碎片物品 ====================
|
||||
|
||||
/** 创建一枚回声碎片(带 NBT 标识,便于精准识别) */
|
||||
public ItemStack createEchoShard() {
|
||||
ItemStack item = new ItemStack(ECHO_SHARD_MATERIAL);
|
||||
ItemMeta meta = item.getItemMeta();
|
||||
if (meta != null) {
|
||||
meta.setDisplayName("§b§l回声碎片");
|
||||
meta.setLore(Arrays.asList(
|
||||
"§7右键/左键 方块或容器",
|
||||
"§7查看领地内的破坏/拿取记录",
|
||||
"§8(领地监控工具)"));
|
||||
NamespacedKey key =
|
||||
new NamespacedKey(plugin, ECHO_SHARD_KEY);
|
||||
meta.getPersistentDataContainer().set(
|
||||
key, PersistentDataType.BYTE, (byte) 1);
|
||||
item.setItemMeta(meta);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 判断物品是否为回声碎片(材质 + NBT 双重校验) */
|
||||
public boolean isEchoShard(ItemStack item) {
|
||||
if (item == null || item.getType() != ECHO_SHARD_MATERIAL)
|
||||
return false;
|
||||
ItemMeta meta = item.getItemMeta();
|
||||
if (meta == null) return false;
|
||||
NamespacedKey key =
|
||||
new NamespacedKey(plugin, ECHO_SHARD_KEY);
|
||||
return meta.getPersistentDataContainer().has(
|
||||
key, PersistentDataType.BYTE);
|
||||
}
|
||||
|
||||
/** 直接发放一枚回声碎片给玩家(GUI 菜单调用) */
|
||||
public void giveEchoShard(Player p) {
|
||||
// ★ SN 管控:1人同种类仅限1个
|
||||
if (plugin.getSnManager() != null
|
||||
&& plugin.getSnManager().isBlocked(
|
||||
p, SnManager.TYPE_ECHO)) {
|
||||
long cdMin = plugin.getSnManager().cooldownMinutesLeft(p.getName());
|
||||
if (cdMin > 0) {
|
||||
p.sendMessage("§c§l[回声碎片] §f注销冷静期未结束,还剩约 §e" + cdMin + " §f分钟,期间无法申领新设备");
|
||||
} else {
|
||||
p.sendMessage("§c§l[回声碎片] §f你已有回声碎片的有效登记,"
|
||||
+ "旧SN未注销前不能重新申领");
|
||||
}
|
||||
return;
|
||||
}
|
||||
ItemStack shard = createEchoShard();
|
||||
if (plugin.getSnManager() != null) {
|
||||
String sn = plugin.getSnManager().applySn(
|
||||
p, SnManager.TYPE_ECHO, "回声碎片");
|
||||
if (sn == null) {
|
||||
p.sendMessage("§c§l[回声碎片] §f申领被SN系统拒绝");
|
||||
return;
|
||||
}
|
||||
plugin.getSnManager().writeSn(
|
||||
shard, sn, SnManager.TYPE_ECHO);
|
||||
}
|
||||
HashMap<Integer, ItemStack> left =
|
||||
p.getInventory().addItem(shard);
|
||||
if (!left.isEmpty()) {
|
||||
p.sendMessage("§c§l[回声碎片] §f你的背包已满,无法发放");
|
||||
} else {
|
||||
p.sendMessage("§a§l[回声碎片] §f你获得了§b回声碎片§f,右键/左键方块或容器查看领地记录");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 记录写入(由 AreaProtection 钩子调用)====================
|
||||
|
||||
/** 记录方块破坏 */
|
||||
public void recordBlockBreak(AreaConfig ac, Player p,
|
||||
Block block) {
|
||||
recordBlock(ac, p, block, "break");
|
||||
}
|
||||
|
||||
/** 记录方块放置 */
|
||||
public void recordBlockPlace(AreaConfig ac, Player p,
|
||||
Block block) {
|
||||
recordBlock(ac, p, block, "place");
|
||||
}
|
||||
|
||||
/** 记录红石元器件状态变化(压力板、按钮、拉杆等) */
|
||||
public void recordRedstoneStateChange(AreaConfig ac, Player p,
|
||||
Block block) {
|
||||
recordBlock(ac, p, block, "state_change");
|
||||
}
|
||||
|
||||
/** ★ 记录告示牌放置/编辑(含内容) */
|
||||
public void recordSignEdit(AreaConfig ac, Player p, Block block,
|
||||
String[] lines, boolean isEdit) {
|
||||
try {
|
||||
int landId = plugin.areaProtection.getLandIdFromDb(ac.name);
|
||||
// 拼接4行内容,空行跳过
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String line : lines) {
|
||||
if (line != null && !line.isEmpty()) {
|
||||
if (sb.length() > 0) sb.append(" | ");
|
||||
sb.append(line);
|
||||
}
|
||||
}
|
||||
String detail = sb.toString();
|
||||
String action = isEdit ? "sign_edit" : "sign_place";
|
||||
plugin.getDb().recordLandBlockWithDetail(
|
||||
ac.name, landId,
|
||||
block.getWorld().getName(),
|
||||
block.getX(), block.getY(), block.getZ(),
|
||||
p.getName(), action,
|
||||
block.getType().name(), detail);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private void recordBlock(AreaConfig ac, Player p, Block block,
|
||||
String action) {
|
||||
try {
|
||||
int landId = plugin.areaProtection.getLandIdFromDb(
|
||||
ac.name);
|
||||
plugin.getDb().recordLandBlock(
|
||||
ac.name, landId,
|
||||
block.getWorld().getName(),
|
||||
block.getX(), block.getY(), block.getZ(),
|
||||
p.getName(), action,
|
||||
block.getType().name());
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录容器打开,并保存打开瞬间容器快照(用于关闭时计算拿取差量)。
|
||||
* 仅在容器确实被允许打开时调用(钩子位于 AreaProtection
|
||||
* 权限/取消检查之后)。
|
||||
*/
|
||||
public void recordContainerOpen(AreaConfig ac, Player p,
|
||||
Location loc, String containerType,
|
||||
Inventory inv) {
|
||||
try {
|
||||
int landId = plugin.areaProtection.getLandIdFromDb(
|
||||
ac.name);
|
||||
plugin.getDb().recordLandContainer(
|
||||
ac.name, landId,
|
||||
loc.getWorld().getName(),
|
||||
loc.getBlockX(), loc.getBlockY(), loc.getBlockZ(),
|
||||
p.getName(), "open", containerType, "", 0);
|
||||
|
||||
ContainerSnapshot snap = new ContainerSnapshot();
|
||||
snap.landName = ac.name;
|
||||
snap.landId = landId;
|
||||
snap.world = loc.getWorld().getName();
|
||||
snap.x = loc.getBlockX();
|
||||
snap.y = loc.getBlockY();
|
||||
snap.z = loc.getBlockZ();
|
||||
snap.containerType = containerType;
|
||||
snap.before = scanInventory(inv);
|
||||
openSnapshots.put(p.getName(), snap);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 容器关闭:计算拿取差量 ====================
|
||||
|
||||
@EventHandler
|
||||
public void onContainerClose(InventoryCloseEvent e) {
|
||||
if (!(e.getPlayer() instanceof Player)) return;
|
||||
Player p = (Player) e.getPlayer();
|
||||
ContainerSnapshot snap = openSnapshots.remove(p.getName());
|
||||
if (snap == null) return;
|
||||
|
||||
Map<String, Integer> after = scanInventory(e.getInventory());
|
||||
|
||||
// 1. 拿取:before > after
|
||||
for (Map.Entry<String, Integer> en : snap.before.entrySet()) {
|
||||
int taken = en.getValue() - after.getOrDefault(en.getKey(), 0);
|
||||
if (taken > 0) {
|
||||
recordContainerChange(snap, p.getName(), "take",
|
||||
en.getKey(), taken);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 存入:after > before
|
||||
for (Map.Entry<String, Integer> en : after.entrySet()) {
|
||||
int put = en.getValue() - snap.before.getOrDefault(en.getKey(), 0);
|
||||
if (put > 0) {
|
||||
recordContainerChange(snap, p.getName(), "put",
|
||||
en.getKey(), put);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void recordContainerChange(ContainerSnapshot snap,
|
||||
String playerName, String action,
|
||||
String detail, int amount) {
|
||||
try {
|
||||
plugin.getDb().recordLandContainer(
|
||||
snap.landName, snap.landId,
|
||||
snap.world, snap.x, snap.y, snap.z,
|
||||
playerName, action, snap.containerType,
|
||||
detail, amount);
|
||||
} catch (Exception ex) {
|
||||
ex.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
/** 扫描容器库存,按"类型名"聚合数量 */
|
||||
private Map<String, Integer> scanInventory(Inventory inv) {
|
||||
Map<String, Integer> map = new HashMap<>();
|
||||
for (ItemStack it : inv.getContents()) {
|
||||
if (it == null || it.getType() == Material.AIR)
|
||||
continue;
|
||||
map.merge(it.getType().name(),
|
||||
it.getAmount(), Integer::sum);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ==================== 回声碎片查询(右键/左键)====================
|
||||
|
||||
@EventHandler
|
||||
public void onShardInteract(PlayerInteractEvent e) {
|
||||
ItemStack item = e.getItem();
|
||||
if (item == null || !isEchoShard(item)) return;
|
||||
Action a = e.getAction();
|
||||
if (a != Action.RIGHT_CLICK_BLOCK && a != Action.LEFT_CLICK_BLOCK
|
||||
&& a != Action.RIGHT_CLICK_AIR
|
||||
&& a != Action.LEFT_CLICK_AIR) return;
|
||||
|
||||
Player p = e.getPlayer();
|
||||
// 阻止误触方块/打开容器
|
||||
e.setCancelled(true);
|
||||
|
||||
Block target = e.getClickedBlock();
|
||||
if (target == null) {
|
||||
// 视线选中的方块(非 deprecated 的 getTargetBlockExact)
|
||||
Block sight = p.getTargetBlockExact(6);
|
||||
if (sight != null && sight.getType() != Material.AIR)
|
||||
target = sight;
|
||||
}
|
||||
if (target == null) {
|
||||
p.sendMessage("§c§l[回声碎片] §f你面前没有可查询的方块或容器");
|
||||
return;
|
||||
}
|
||||
Location loc = target.getLocation();
|
||||
lastQueryLocations.put(p.getUniqueId(), loc);
|
||||
queryAndPrint(p, loc);
|
||||
}
|
||||
|
||||
private void queryAndPrint(Player p, Location loc) {
|
||||
queryAndPrint(p, loc, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 收集查询坐标组:大箱子(double chest)由两个方块组成,
|
||||
* 记录写入时用"被点击的那一半"坐标,查询时用"准心对准的那一半"坐标,
|
||||
* 两边坐标不一致就会查不到记录。这里沿同层同类型箱子扩展,
|
||||
* 把整只大箱子的所有坐标一起查出来再合并。
|
||||
*/
|
||||
private List<Location> collectQueryLocations(Location loc) {
|
||||
List<Location> result = new ArrayList<>();
|
||||
result.add(loc);
|
||||
Block start;
|
||||
try {
|
||||
start = loc.getBlock();
|
||||
} catch (Exception ex) {
|
||||
return result; // 世界未加载等异常,退回单点查询
|
||||
}
|
||||
Material type = start.getType();
|
||||
// 只有能拼成大箱子的类型才需要扩展
|
||||
if (type != Material.CHEST && type != Material.TRAPPED_CHEST) {
|
||||
return result;
|
||||
}
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
seen.add(keyOf(start));
|
||||
Deque<Block> queue = new ArrayDeque<>();
|
||||
queue.add(start);
|
||||
int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
|
||||
while (!queue.isEmpty()) {
|
||||
Block cur = queue.poll();
|
||||
for (int[] d : dirs) {
|
||||
Block n = cur.getRelative(d[0], 0, d[1]);
|
||||
if (n.getType() != type) continue;
|
||||
if (seen.add(keyOf(n))) {
|
||||
result.add(n.getLocation());
|
||||
queue.add(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String keyOf(Block b) {
|
||||
return b.getX() + "," + b.getY() + "," + b.getZ();
|
||||
}
|
||||
|
||||
private void queryAndPrint(Player p, Location loc, int page) {
|
||||
String world = loc.getWorld().getName();
|
||||
int x = loc.getBlockX(), y = loc.getBlockY(),
|
||||
z = loc.getBlockZ();
|
||||
|
||||
// ★ 大箱子:把整只箱子的所有半边坐标都查一遍
|
||||
List<Location> group = collectQueryLocations(loc);
|
||||
|
||||
// 按天分页:page=0今日,1昨日,2前日...
|
||||
List<Map<String, Object>> rawBlocks = new ArrayList<>();
|
||||
List<Map<String, Object>> rawContainers = new ArrayList<>();
|
||||
for (Location g : group) {
|
||||
int gx = g.getBlockX(), gy = g.getBlockY(), gz = g.getBlockZ();
|
||||
rawBlocks.addAll(plugin.getDb().getLandBlockLogAt(
|
||||
world, gx, gy, gz, QUERY_LIMIT, page));
|
||||
rawContainers.addAll(plugin.getDb().getLandContainerLogAt(
|
||||
world, gx, gy, gz, QUERY_LIMIT, page));
|
||||
}
|
||||
sortByTimeDesc(rawBlocks);
|
||||
sortByTimeDesc(rawContainers);
|
||||
List<Map<String, Object>> blocks = truncate(rawBlocks);
|
||||
List<Map<String, Object>> containers = truncate(rawContainers);
|
||||
|
||||
if (blocks.isEmpty() && containers.isEmpty()) {
|
||||
if (page > 0) {
|
||||
p.sendMessage("§6§l[回声碎片] §e坐标 ("
|
||||
+ x + "," + y + "," + z + ") 第" + (page + 1) + "页无记录");
|
||||
} else {
|
||||
p.sendMessage("§6§l[回声碎片] §f坐标 ("
|
||||
+ x + "," + y + "," + z + ") 暂无领地记录");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 收集所有需要翻译的材料名
|
||||
Set<String> materials = new LinkedHashSet<>();
|
||||
for (Map<String, Object> r : blocks) {
|
||||
materials.add((String) r.get("material"));
|
||||
}
|
||||
for (Map<String, Object> r : containers) {
|
||||
if ("take".equals(r.get("action")) || "put".equals(r.get("action"))) {
|
||||
materials.add((String) r.get("detail"));
|
||||
}
|
||||
String ct = (String) r.get("container_type");
|
||||
if (ct != null && !ct.isEmpty()) {
|
||||
materials.add(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查下一页是否有数据(决定是否显示"下一页"链接)
|
||||
boolean hasNext = false;
|
||||
for (Location g : group) {
|
||||
int gx = g.getBlockX(), gy = g.getBlockY(), gz = g.getBlockZ();
|
||||
if (!plugin.getDb().getLandBlockLogAt(world, gx, gy, gz, 1, page + 1).isEmpty()) {
|
||||
hasNext = true;
|
||||
break;
|
||||
}
|
||||
if (!plugin.getDb().getLandContainerLogAt(world, gx, gy, gz, 1, page + 1).isEmpty()) {
|
||||
hasNext = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final int currentPage = page;
|
||||
final boolean hasNextPage = hasNext;
|
||||
// 异步批量翻译
|
||||
MaterialTranslator.translateBatch(materials)
|
||||
.thenAccept(translations -> {
|
||||
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||
printResults(p, x, y, z, blocks, containers, translations, currentPage, hasNextPage);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 按 time 倒序(新→旧),兼容 Long/Number 两种取值 */
|
||||
private void sortByTimeDesc(List<Map<String, Object>> list) {
|
||||
list.sort((a, b) -> Long.compare(timeOf(b), timeOf(a)));
|
||||
}
|
||||
|
||||
private long timeOf(Map<String, Object> r) {
|
||||
Object v = r.get("time");
|
||||
return v instanceof Number ? ((Number) v).longValue() : 0L;
|
||||
}
|
||||
|
||||
/** 截断到 QUERY_LIMIT 条,返回新列表 */
|
||||
private List<Map<String, Object>> truncate(List<Map<String, Object>> list) {
|
||||
if (list.size() <= QUERY_LIMIT) return list;
|
||||
return new ArrayList<>(list.subList(0, QUERY_LIMIT));
|
||||
}
|
||||
|
||||
private void printResults(Player p, int x, int y, int z,
|
||||
List<Map<String, Object>> blocks,
|
||||
List<Map<String, Object>> containers,
|
||||
Map<String, String> translations) {
|
||||
printResults(p, x, y, z, blocks, containers, translations, 0, false);
|
||||
}
|
||||
|
||||
private void printResults(Player p, int x, int y, int z,
|
||||
List<Map<String, Object>> blocks,
|
||||
List<Map<String, Object>> containers,
|
||||
Map<String, String> translations,
|
||||
int page, boolean hasNext) {
|
||||
String pageLabel = page == 0 ? "今日" : (page == 1 ? "昨日" : (page + 1) + "日前");
|
||||
p.sendMessage("§6§l[回声碎片] §e坐标 ("
|
||||
+ x + "," + y + "," + z + ") 的记录§7(" + pageLabel + "):");
|
||||
|
||||
if (!blocks.isEmpty()) {
|
||||
p.sendMessage("§b— 方块记录 —");
|
||||
for (Map<String, Object> r : blocks) {
|
||||
String action = (String) r.get("action");
|
||||
String act;
|
||||
if ("break".equals(action)) {
|
||||
act = "§c破坏";
|
||||
} else if ("place".equals(action)) {
|
||||
act = "§a放置";
|
||||
} else if ("state_change".equals(action)) {
|
||||
act = "§e状态变化";
|
||||
} else if ("sign_place".equals(action)) {
|
||||
act = "§a放置告示牌";
|
||||
} else if ("sign_edit".equals(action)) {
|
||||
act = "§b编辑告示牌";
|
||||
} else {
|
||||
act = "§7" + action;
|
||||
}
|
||||
String mat = (String) r.get("material");
|
||||
String zh = translations.getOrDefault(mat, MaterialTranslator.toReadable(mat));
|
||||
StringBuilder msg = new StringBuilder();
|
||||
msg.append(" §7").append(fmt((Long) r.get("time")))
|
||||
.append(" §f").append(r.get("player_name"))
|
||||
.append(" ").append(act)
|
||||
.append(" §e").append(mat).append(" §a").append(zh);
|
||||
String detail = (String) r.get("detail");
|
||||
if (detail != null && !detail.isEmpty()
|
||||
&& ("sign_place".equals(action) || "sign_edit".equals(action))) {
|
||||
msg.append(" §7[§f").append(detail).append("§7]");
|
||||
}
|
||||
p.sendMessage(msg.toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (!containers.isEmpty()) {
|
||||
p.sendMessage("§b— 容器记录 §7(" + pageLabel + ")—");
|
||||
for (Map<String, Object> r : containers) {
|
||||
String act = (String) r.get("action");
|
||||
if ("open".equals(act)) {
|
||||
String ct = (String) r.get("container_type");
|
||||
String ctZh = translations.getOrDefault(ct, MaterialTranslator.toReadable(ct));
|
||||
p.sendMessage(" §7" + fmt((Long) r.get("time"))
|
||||
+ " §f" + r.get("player_name")
|
||||
+ " §d打开§7[" + ct + "] §a" + ctZh);
|
||||
} else if ("take".equals(act)) {
|
||||
String mat = (String) r.get("detail");
|
||||
String zh = translations.getOrDefault(mat, MaterialTranslator.toReadable(mat));
|
||||
p.sendMessage(" §7" + fmt((Long) r.get("time"))
|
||||
+ " §f" + r.get("player_name")
|
||||
+ " §c拿取 §e" + mat + " §a" + zh
|
||||
+ " §7x" + r.get("amount"));
|
||||
} else if ("put".equals(act)) {
|
||||
String mat = (String) r.get("detail");
|
||||
String zh = translations.getOrDefault(mat, MaterialTranslator.toReadable(mat));
|
||||
p.sendMessage(" §7" + fmt((Long) r.get("time"))
|
||||
+ " §f" + r.get("player_name")
|
||||
+ " §a存入 §e" + mat + " §a" + zh
|
||||
+ " §7x" + r.get("amount"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 可点击翻页导航
|
||||
Component nav = Component.text(" §7[ ");
|
||||
if (page > 0) {
|
||||
nav = nav.append(Component.text("§e← 上一页")
|
||||
.clickEvent(ClickEvent.runCommand("/landrec log " + page))
|
||||
.hoverEvent(HoverEvent.showText(Component.text("§7点击查看前一日记录"))));
|
||||
nav = nav.append(Component.text(" §7| "));
|
||||
}
|
||||
nav = nav.append(Component.text("§f第" + (page + 1) + "页"));
|
||||
if (hasNext) {
|
||||
nav = nav.append(Component.text(" §7| ")
|
||||
.append(Component.text("§e下一页 →")
|
||||
.clickEvent(ClickEvent.runCommand("/landrec log " + (page + 2)))
|
||||
.hoverEvent(HoverEvent.showText(Component.text("§7点击查看后一日记录")))));
|
||||
}
|
||||
nav = nav.append(Component.text(" §7]"));
|
||||
p.sendMessage(nav);
|
||||
}
|
||||
|
||||
private String fmt(long ms) {
|
||||
return sdf.format(new Date(ms));
|
||||
}
|
||||
|
||||
// ==================== 命令:/landrec ====================
|
||||
|
||||
public boolean handleCommand(CommandSender sender,
|
||||
String[] args) {
|
||||
if (args.length == 0) {
|
||||
sendHelp(sender);
|
||||
return true;
|
||||
}
|
||||
String sub = args[0];
|
||||
if (sub.equalsIgnoreCase("give")) {
|
||||
// 发放权限...
|
||||
boolean isOp = sender.isOp();
|
||||
boolean isAreaAdmin = (sender instanceof Player)
|
||||
&& plugin.areaProtection.isAreaAdmin((Player) sender);
|
||||
|
||||
Player target;
|
||||
if (args.length >= 2) {
|
||||
if (!isOp && !isAreaAdmin) {
|
||||
sender.sendMessage("§c§l[回声碎片] §f你无权向他人发放回声碎片");
|
||||
return true;
|
||||
}
|
||||
target = Bukkit.getPlayerExact(args[1]);
|
||||
if (target == null) {
|
||||
sender.sendMessage("§c§l[回声碎片] §f玩家 " + args[1] + " 不在线");
|
||||
return true;
|
||||
}
|
||||
} else if (sender instanceof Player) {
|
||||
target = (Player) sender;
|
||||
} else {
|
||||
sender.sendMessage("§c§l[回声碎片] §f请指定要发放的玩家");
|
||||
return true;
|
||||
}
|
||||
giveEchoShard(target);
|
||||
if (target != sender)
|
||||
sender.sendMessage("§a§l[回声碎片] §f已发放回声碎片给 " + target.getName());
|
||||
return true;
|
||||
}
|
||||
if (sub.equalsIgnoreCase("log")) {
|
||||
if (!(sender instanceof Player)) {
|
||||
sender.sendMessage("§c§l[回声碎片] §f此命令仅限玩家使用");
|
||||
return true;
|
||||
}
|
||||
Player p = (Player) sender;
|
||||
int page = 0; // 默认今日
|
||||
boolean isNav = false; // true=从翻页链接跳转,复用 lastQueryLocations
|
||||
if (args.length >= 2) {
|
||||
try {
|
||||
page = Math.max(0, Integer.parseInt(args[1]) - 1);
|
||||
isNav = true;
|
||||
} catch (NumberFormatException ignored) {
|
||||
sender.sendMessage("§c§l[回声碎片] §f页码格式错误");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 翻页时复用上次查询坐标,避免 getTargetBlockExact 玩家未对准
|
||||
Location navLoc = isNav ? lastQueryLocations.get(p.getUniqueId()) : null;
|
||||
if (navLoc != null) {
|
||||
queryAndPrint(p, navLoc, page);
|
||||
return true;
|
||||
}
|
||||
// 首次查询或缓存过期,取准心方块
|
||||
Block target = p.getTargetBlockExact(6);
|
||||
if (target == null || target.getType() == Material.AIR) {
|
||||
sender.sendMessage("§c§l[回声碎片] §f你面前没有可查询的方块或容器");
|
||||
return true;
|
||||
}
|
||||
Location loc = target.getLocation();
|
||||
if (!isNav) {
|
||||
lastQueryLocations.put(p.getUniqueId(), loc);
|
||||
}
|
||||
queryAndPrint(p, loc, page);
|
||||
return true;
|
||||
}
|
||||
sendHelp(sender);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void sendHelp(CommandSender sender) {
|
||||
sender.sendMessage("§6§l[回声碎片] §e指令:");
|
||||
sender.sendMessage(" §7/landrec give §f- 给自己发放一枚回声碎片");
|
||||
sender.sendMessage(" §7/landrec give <玩家> §f- 向他人发放(需 OP/领地管理员)");
|
||||
sender.sendMessage(" §7/landrec log §f- 查看当前方块今日记录");
|
||||
sender.sendMessage(" §7/landrec log <页码> §f- 查看指定日期的记录(1=今日 2=昨日 ...)");
|
||||
sender.sendMessage(" §7手持回声碎片右键/左键方块或容器 §f- 查看该处今日记录");
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,10 @@ public class LoginManager {
|
||||
plugin.getDb().createUser(
|
||||
name, hash, salt);
|
||||
plugin.getLoggedIn().add(name);
|
||||
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
|
||||
if (plugin.webManager != null) {
|
||||
plugin.webManager.recordJavaLogin(name, plugin.getPlayerIP(p));
|
||||
}
|
||||
p.setAllowFlight(false);
|
||||
p.setFlying(false);
|
||||
plugin.getDb().setLoggedIn(name, true);
|
||||
@@ -86,6 +90,8 @@ public class LoginManager {
|
||||
p.sendMessage(plugin.getConfig2()
|
||||
.msg("reg_success"));
|
||||
playRegisterSound(p);
|
||||
// ★ 注册成功:actionbar 提示1分钟保存刚才的账号和密码
|
||||
plugin.showCredentialSaveReminder(p, name, pwd);
|
||||
|
||||
try {
|
||||
org.bukkit.plugin.Plugin cy =
|
||||
@@ -100,6 +106,17 @@ public class LoginManager {
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
// ★ 注册后立即同步用户数据到PHP
|
||||
if (plugin.webManager != null) {
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
try { plugin.webManager.syncUserRegistrations(); }
|
||||
catch (Exception e) {
|
||||
plugin.getLogger().warning("[Web通信] 注册后同步用户异常: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -118,7 +135,16 @@ public class LoginManager {
|
||||
|
||||
public boolean handleLogin(Player p,
|
||||
String[] args) {
|
||||
|
||||
String name = p.getName();
|
||||
// ★ 在 handleLogin 方法开头
|
||||
if (plugin.getNeedsPasswordChange().contains(name)
|
||||
&& plugin.getLoggedIn().contains(name)) {
|
||||
// 已登录但还没改密码,只允许执行 /sdf1_login pw
|
||||
p.sendMessage("§c请先修改密码: /sdf1_login pw <旧密码> <新密码>");
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (plugin.getLoggedIn().contains(name)) {
|
||||
p.sendMessage(plugin.getConfig2()
|
||||
@@ -130,6 +156,16 @@ public class LoginManager {
|
||||
.msg("not_registered"));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ★ 风控检查:封禁期内拒绝登录
|
||||
if (plugin.getRiskControl().isBanned(name)) {
|
||||
int remaining = plugin.getRiskControl()
|
||||
.getBanRemainingSeconds(name);
|
||||
p.sendMessage("§c§l[安全风控] §f账号因密码错误过多已被封禁,"
|
||||
+ "剩余 §e" + remaining + " §f秒");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (args.length < 1) {
|
||||
p.sendMessage("§c用法: /login <密码>");
|
||||
return true;
|
||||
@@ -152,61 +188,37 @@ public class LoginManager {
|
||||
plugin.getDb().checkPassword(
|
||||
name, hash);
|
||||
if (matchMain) {
|
||||
plugin.getLoggedIn().add(name);
|
||||
plugin.getDb().setLoggedIn(
|
||||
name, true);
|
||||
plugin.getDb().setLoggedIn(
|
||||
name, true);
|
||||
plugin.getDb().setField(name,
|
||||
"last_login_time",
|
||||
System.currentTimeMillis());
|
||||
plugin.getDb().setField(name,
|
||||
"last_online_check",
|
||||
System.currentTimeMillis());
|
||||
// 只在有有效备份时恢复
|
||||
plugin.restoreInventory(p);
|
||||
plugin.recordIPLogin(p);
|
||||
plugin.giveMenuSnowball(p);
|
||||
p.sendMessage(plugin.getConfig2()
|
||||
.msg("login_success"));
|
||||
activateCY(p, name);
|
||||
// ★ IP风控双验证第一步:只认密码,不解冻、不恢复背包
|
||||
if (markDualStep1(p, false)) return true;
|
||||
finishPasswordLogin(p, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean matchTemp =
|
||||
String pwdResult =
|
||||
plugin.getDb()
|
||||
.checkPasswordOrTemp(
|
||||
.checkPasswordWithFallback(
|
||||
name, hash);
|
||||
if (matchTemp) {
|
||||
plugin.getLoggedIn().add(name);
|
||||
plugin.getDb().setLoggedIn(
|
||||
name, true);
|
||||
p.setAllowFlight(false);
|
||||
p.setFlying(false);
|
||||
plugin.getDb().setField(name,
|
||||
"last_login_time",
|
||||
System.currentTimeMillis());
|
||||
plugin.getDb().setField(name,
|
||||
"last_online_check",
|
||||
System.currentTimeMillis());
|
||||
plugin.restoreInventory(p);
|
||||
plugin.recordIPLogin(p);
|
||||
plugin.giveMenuSnowball(p);
|
||||
plugin.getNeedsPasswordChange()
|
||||
.add(name);
|
||||
p.sendMessage(plugin.getConfig2()
|
||||
.msg("login_success"));
|
||||
p.sendMessage(
|
||||
"§c§l[警告] §f您使用的是临时密码,请尽快修改密码!");
|
||||
p.sendMessage(
|
||||
"§7用法: /sdf1_login pw");
|
||||
activateCY(p, name);
|
||||
if (pwdResult != null) {
|
||||
// ★ IP风控双验证第一步:只认密码,不解冻、不恢复背包
|
||||
if (markDualStep1(p, true)) return true;
|
||||
finishPasswordLogin(p, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// ★ 密码错误,记录风控
|
||||
int remaining = plugin.getRiskControl()
|
||||
.recordFailAttempt(p);
|
||||
if (remaining < 0) {
|
||||
// 已被封禁(triggerBan内部已踢出)
|
||||
return true;
|
||||
}
|
||||
p.sendMessage(plugin.getConfig2()
|
||||
.msg("login_failed"));
|
||||
if (remaining > 0) {
|
||||
p.sendMessage("§c§l[风控] §f剩余 §e"
|
||||
+ remaining + " §f次尝试机会");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().severe(
|
||||
"[Sdf1_login] handleLogin异常: "
|
||||
@@ -219,6 +231,82 @@ public class LoginManager {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* IP风控「密码 + 2FA」顺序两关的第一步。
|
||||
* 密码正确只记录进度:<b>不加入 loggedIn、不恢复背包</b>,必须等第二步 /2fa code 通过才收口。
|
||||
*
|
||||
* @return true = 该玩家处于双验证流程,本轮 /login 到此为止
|
||||
*/
|
||||
private boolean markDualStep1(Player p, boolean tempPassword) {
|
||||
TwoFactorManager twofa = plugin.getTwofa();
|
||||
String name = p.getName();
|
||||
if (twofa == null || !twofa.isDualVerify(p)) return false;
|
||||
twofa.onDualPasswordPassed(p, tempPassword);
|
||||
// 密码这一步确实对了 → 重置「密码错误次数」风控(不代表已登录,仍不解冻)
|
||||
plugin.getRiskControl().onLoginSuccess(name);
|
||||
p.sendMessage("§a§l[双重验证] §a第一步密码验证通过(尚未解冻)");
|
||||
p.sendMessage("§7第二步: 执行 §f/2fa code <动态码|恢复代码>");
|
||||
p.sendMessage("§c两步全部通过后才会解冻并恢复背包");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码登录成功的统一收口:解冻 + 恢复背包 + 记录登录信息。
|
||||
* 调用点:/login 主密码、/login 临时密码、IP风控双验证第二步通过后。
|
||||
*/
|
||||
public void finishPasswordLogin(Player p, boolean tempPassword) {
|
||||
String name = p.getName();
|
||||
// ★ 登录成功,重置风控
|
||||
plugin.getRiskControl().onLoginSuccess(name);
|
||||
plugin.getLoggedIn().add(name);
|
||||
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
|
||||
if (plugin.webManager != null) {
|
||||
plugin.webManager.recordJavaLogin(name, plugin.getPlayerIP(p));
|
||||
}
|
||||
plugin.getDb().setLoggedIn(name, true);
|
||||
if (!tempPassword) {
|
||||
// 主密码路径保留原有的重复写入(行为与历史版本一致)
|
||||
plugin.getDb().setLoggedIn(name, true);
|
||||
}
|
||||
plugin.getDb().setField(name, "last_login_time",
|
||||
System.currentTimeMillis());
|
||||
plugin.getDb().setField(name, "last_online_check",
|
||||
System.currentTimeMillis());
|
||||
// ★ 立即同步在线状态到PHP(异步,避免阻塞主线程)
|
||||
if (!tempPassword && plugin.webManager != null) {
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
try {
|
||||
plugin.webManager.syncOnlinePlayers();
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[Web通信] 异步syncOnlinePlayers异常: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
// 登录成功关闭飞行
|
||||
p.setAllowFlight(false);
|
||||
p.setFlying(false);
|
||||
// 只在有有效备份时恢复
|
||||
plugin.restoreInventory(p);
|
||||
plugin.recordIPLogin(p);
|
||||
plugin.giveMenuSnowball(p);
|
||||
if (tempPassword) {
|
||||
// ★ 确保这行存在
|
||||
plugin.getNeedsPasswordChange().add(name);
|
||||
}
|
||||
p.sendMessage(plugin.getConfig2().msg("login_success"));
|
||||
if (tempPassword) {
|
||||
p.sendMessage("§c§l[警告] §f您使用的是临时密码,请尽快修改密码!");
|
||||
p.sendMessage("§7用法: /sdf1_login pw");
|
||||
}
|
||||
activateCY(p, name);
|
||||
// ★ 登录后恢复区域效果
|
||||
Sdf1_login.AreaProtection areaProt =
|
||||
plugin.getAreaProtection();
|
||||
if (areaProt != null) {
|
||||
areaProt.onPlayerJoin(p);
|
||||
}
|
||||
}
|
||||
|
||||
public void handleReset(Player p) {
|
||||
String name = p.getName();
|
||||
String emailAddr = (String)
|
||||
|
||||
@@ -0,0 +1,962 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
|
||||
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.CharsetDecoder;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 维护模式(白名单)管理器
|
||||
*
|
||||
* 配置文件:plugins/Sdf1_login/白名单.txt,分两个段落:
|
||||
* [控制段] 启用 / 开始时间 / 结束时间 / 时长 / 消息 等键值对
|
||||
* [白名单段] 每行一个玩家名(也可用逗号分隔多个)
|
||||
*
|
||||
* 特性:
|
||||
* - 24 小时硬封顶:无论怎么配,单次维护窗口最长 24 小时,到点自动结束
|
||||
* - 多格式时间:10位秒级时间戳 / 13位毫秒 / yyyy-MM-dd HH:mm / 2026年9月28日14时30分 / 只写 HH:mm(今天)
|
||||
* - 未写时长与结束时间时默认 24 小时
|
||||
* - 消息支持 & 与 § 颜色码、整段 JSON 文本组件、\n 与 <br> 换行
|
||||
* - 变量:username / 用户名、starttime / 开始时间、endtime / 结束时间
|
||||
* - 玩家登录(异步预登录)、加入、退出时热重载配置,改完文件即生效,无需重启
|
||||
* - 拦截发生在异步预登录阶段,不给未授权客户端发送任何世界/区块数据包
|
||||
* - 独立于 MC 自带白名单,互不影响
|
||||
*/
|
||||
public class MaintenanceManager implements Listener {
|
||||
|
||||
/** 24小时硬封顶 */
|
||||
private static final long MAX_WINDOW_MS = 24L * 60L * 60L * 1000L;
|
||||
/** 未配置时长/结束时间时的默认窗口 */
|
||||
private static final long DEFAULT_WINDOW_MS = MAX_WINDOW_MS;
|
||||
|
||||
private static final String DEFAULT_MESSAGE =
|
||||
"&c&l[服务器维护] &7服务器正在维护中,请稍后再试&7(结束时间: &e{结束时间}&7)";
|
||||
|
||||
private static final Pattern NUM_PAT = Pattern.compile("\\d+");
|
||||
private static final Pattern DUR_PAT = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*([a-zA-Z\\u4e00-\\u9fa5]*)");
|
||||
private static final Pattern AMP_COLOR_PAT = Pattern.compile("&([0-9a-fk-orA-FK-OR])");
|
||||
|
||||
private final Main plugin;
|
||||
private final File file;
|
||||
|
||||
/** 解析互斥:异步登录线程与主线程(join/quit)都可能触发热重载 */
|
||||
private final Object lock = new Object();
|
||||
|
||||
private volatile long lastMtime = -1L;
|
||||
private volatile boolean enabled = false;
|
||||
private volatile long windowStart = 0L;
|
||||
private volatile long windowEnd = 0L;
|
||||
private volatile boolean opBypass = true;
|
||||
private volatile String rawMessage = DEFAULT_MESSAGE;
|
||||
private volatile Set<String> whitelistLower = Collections.emptySet();
|
||||
|
||||
/** 未显式写「开始时间」时记住本轮起点,避免热重载把窗口反复清零 */
|
||||
private long implicitStart = 0L;
|
||||
/** 到期日志只打一次 */
|
||||
private volatile boolean expiryLogged = false;
|
||||
/** OP 查询缓存(异步线程里只查一次) */
|
||||
private final ConcurrentHashMap<String, Boolean> opCache = new ConcurrentHashMap<>();
|
||||
|
||||
public MaintenanceManager(Main plugin) {
|
||||
this.plugin = plugin;
|
||||
this.file = new File(plugin.getDataFolder(), "白名单.txt");
|
||||
try {
|
||||
Files.createDirectories(plugin.getDataFolder().toPath());
|
||||
} catch (IOException e) {
|
||||
plugin.getLogger().warning("[维护模式] 创建数据目录失败: " + e.getMessage());
|
||||
}
|
||||
if (!file.exists()) {
|
||||
writeDefaultFile();
|
||||
}
|
||||
reload(true);
|
||||
}
|
||||
|
||||
// ==================== 对外接口 ====================
|
||||
|
||||
/** 维护窗口是否正在生效 */
|
||||
public boolean isActive() {
|
||||
if (!enabled) return false;
|
||||
long now = System.currentTimeMillis();
|
||||
return now >= windowStart && now < windowEnd;
|
||||
}
|
||||
|
||||
/** 玩家是否在白名单内(大小写不敏感) */
|
||||
public boolean isWhitelisted(String name) {
|
||||
if (name == null || name.isEmpty()) return false;
|
||||
return whitelistLower.contains(name.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
public long getWindowStart() {
|
||||
return windowStart;
|
||||
}
|
||||
|
||||
public long getWindowEnd() {
|
||||
return windowEnd;
|
||||
}
|
||||
|
||||
public Set<String> getWhitelist() {
|
||||
return whitelistLower;
|
||||
}
|
||||
|
||||
// ==================== 配置文件 ====================
|
||||
|
||||
private void writeDefaultFile() {
|
||||
String tpl = String.join("\n",
|
||||
"# =========================================================",
|
||||
"# 维护模式(白名单)配置文件",
|
||||
"# 修改保存后无需重启:玩家尝试登录 / 加入 / 退出时自动热加载",
|
||||
"#",
|
||||
"# [控制段] 用「键 = 值」写服务器开关、时间与提示语",
|
||||
"# [白名单段] 严格一行一个玩家名(一行只写一个,不支持逗号/空格分隔)",
|
||||
"# (标题行漏写也能兜底收录,但请尽量保留 [白名单] 标题)",
|
||||
"#",
|
||||
"# 可用键:",
|
||||
"# 启用 = true / false",
|
||||
"# 开始时间 = 时间戳 / 2026-09-28 20:00 / 2026年9月28日20时00分(留空=立即开始)",
|
||||
"# 结束时间 = 同上(留空则按时长或默认24小时计算)",
|
||||
"# 时长 = 24h / 12小时 / 90分钟(单次维护最长 24 小时,硬封顶)",
|
||||
"# OP可绕过 = true / false(OP 是否可以绕过维护直接进服)",
|
||||
"# 消息 = 支持 &a§a 颜色码、整段 JSON(对象 {...} 或数组 [...])、\\n 与 <br> 换行",
|
||||
"# 可用变量 {username} {starttime} {endtime}(带花括号,JSON 内同样生效)",
|
||||
"# =========================================================",
|
||||
"",
|
||||
"# ===== 控制段 =====",
|
||||
"启用 = false",
|
||||
"开始时间 =",
|
||||
"结束时间 =",
|
||||
"时长 = 24h",
|
||||
"OP可绕过 = true",
|
||||
"消息 = " + DEFAULT_MESSAGE,
|
||||
"",
|
||||
"# ===== 白名单段 =====",
|
||||
"[白名单]",
|
||||
"");
|
||||
try {
|
||||
Files.write(file.toPath(), tpl.getBytes(StandardCharsets.UTF_8));
|
||||
plugin.getLogger().info("[维护模式] 已生成默认配置: " + file.getAbsolutePath());
|
||||
} catch (IOException e) {
|
||||
plugin.getLogger().warning("[维护模式] 写入默认配置失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 玩家登录/加入/退出时调用:文件变了就重新解析 */
|
||||
public void reloadIfChanged() {
|
||||
if (!file.exists()) return;
|
||||
long m;
|
||||
try {
|
||||
m = file.lastModified();
|
||||
} catch (Exception e) {
|
||||
return;
|
||||
}
|
||||
if (m == lastMtime) return;
|
||||
reload(true);
|
||||
}
|
||||
|
||||
private void reload(boolean force) {
|
||||
synchronized (lock) {
|
||||
long m;
|
||||
try {
|
||||
m = file.lastModified();
|
||||
} catch (Exception e) {
|
||||
return;
|
||||
}
|
||||
if (!force && m == lastMtime) return;
|
||||
|
||||
String text;
|
||||
try {
|
||||
text = decode(Files.readAllBytes(file.toPath()));
|
||||
} catch (IOException e) {
|
||||
plugin.getLogger().warning("[维护模式] 读取配置失败: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
// 行扫描抽成 static parseConfig(含段标题丢失兜底 / 条目清洗),便于单测
|
||||
ParsedConfig pc = parseConfig(text, plugin.getLogger());
|
||||
Map<String, String> ctl = pc.ctl;
|
||||
Set<String> wl = pc.wl;
|
||||
|
||||
boolean wasActive = isActive();
|
||||
boolean wasEnabled = this.enabled;
|
||||
|
||||
String enRaw = pick(ctl,
|
||||
"启用", "开启", "开关", "维护", "enable", "enabled", "maintenance", "on");
|
||||
boolean en = toBool(enRaw, false);
|
||||
Long st = parseTime(pick(ctl, "开始时间", "开始", "起始时间", "start", "starttime", "begintime"));
|
||||
Long enT = parseTime(pick(ctl, "结束时间", "结束", "截止时间", "end", "endtime"));
|
||||
Long dur = parseDuration(pick(ctl, "时长", "持续时间", "持续", "duration", "hours", "小时"));
|
||||
String opRaw = pick(ctl, "OP可绕过", "op可绕过", "OP绕过", "管理员绕过",
|
||||
"opbypass", "op_bypass", "op", "bypass");
|
||||
boolean op = toBool(opRaw, true);
|
||||
if (enRaw != null && !isBoolWord(enRaw)) {
|
||||
plugin.getLogger().warning("[维护模式] 「启用」的值不是可识别的布尔词: ["
|
||||
+ enRaw + "],按 false 处理(请写 true / false)");
|
||||
}
|
||||
if (opRaw != null && !isBoolWord(opRaw)) {
|
||||
plugin.getLogger().warning("[维护模式] 「OP可绕过」的值不是可识别的布尔词: ["
|
||||
+ opRaw + "],按 false 处理(请写 true / false)");
|
||||
}
|
||||
String msg = pick(ctl, "消息", "提示", "维护消息", "踢出消息", "message", "msg", "kickmessage");
|
||||
if (msg == null || msg.isEmpty()) msg = DEFAULT_MESSAGE;
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (st == null) {
|
||||
// 没写开始时间:沿用上一轮未过期的起点,否则从现在开始
|
||||
if (implicitStart > 0L && implicitStart + MAX_WINDOW_MS > now) {
|
||||
st = implicitStart;
|
||||
} else {
|
||||
st = now;
|
||||
}
|
||||
}
|
||||
implicitStart = st;
|
||||
|
||||
long end;
|
||||
if (enT != null) {
|
||||
end = enT;
|
||||
} else if (dur != null) {
|
||||
end = st + dur;
|
||||
} else {
|
||||
end = st + DEFAULT_WINDOW_MS;
|
||||
}
|
||||
// ★ 24小时硬封顶
|
||||
if (end > st + MAX_WINDOW_MS) end = st + MAX_WINDOW_MS;
|
||||
if (end < st) end = st;
|
||||
|
||||
this.enabled = en;
|
||||
this.windowStart = st;
|
||||
this.windowEnd = end;
|
||||
this.opBypass = op;
|
||||
this.rawMessage = msg;
|
||||
Set<String> lowered = new LinkedHashSet<>();
|
||||
for (String n : wl) lowered.add(n.toLowerCase(Locale.ROOT));
|
||||
this.whitelistLower = Collections.unmodifiableSet(lowered);
|
||||
this.lastMtime = m;
|
||||
if (wasEnabled && !en) {
|
||||
this.implicitStart = 0L; // 手动关掉后,下次开启重新计时
|
||||
}
|
||||
this.expiryLogged = false;
|
||||
this.opCache.clear();
|
||||
|
||||
boolean nowActive = isActive();
|
||||
if (nowActive && !wasActive) {
|
||||
plugin.getLogger().info("[维护模式] ★ 维护已开启,白名单 " + whitelistLower.size()
|
||||
+ " 人,窗口 " + fmt(windowStart) + " ~ " + fmt(windowEnd));
|
||||
} else if (nowActive) {
|
||||
plugin.getLogger().info("[维护模式] 配置已热重载,白名单 " + whitelistLower.size()
|
||||
+ " 人,窗口 " + fmt(windowStart) + " ~ " + fmt(windowEnd));
|
||||
} else if (wasActive) {
|
||||
plugin.getLogger().info("[维护模式] 维护已结束");
|
||||
}
|
||||
// ★ 直接把读到的条目与控制段键打出来,线上「配了却进不去」时一眼能对账
|
||||
if (nowActive && !whitelistLower.isEmpty()) {
|
||||
plugin.getLogger().info("[维护模式] 白名单条目: [" + summarize(whitelistLower) + "]");
|
||||
}
|
||||
if (nowActive && whitelistLower.isEmpty()) {
|
||||
plugin.getLogger().warning("[维护模式] ⚠ 维护已开启但白名单为 0 人!段标题[白名单]识别="
|
||||
+ pc.sawWhiteHeader
|
||||
+ (opBypass ? ",OP 仍可绕过" : ",且 OP 不可绕过:所有玩家都会被拦在外面")
|
||||
+ ";控制段读到的键: " + ctl.keySet() + ",请检查 [白名单] 段标题与条目写法");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 登录拦截 ====================
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST)
|
||||
public void onPreLogin(AsyncPlayerPreLoginEvent e) {
|
||||
// 登录阶段热重载:改完文件直接重连即可生效
|
||||
try {
|
||||
reloadIfChanged();
|
||||
} catch (Throwable t) {
|
||||
// 配置读取异常不能影响正常登录
|
||||
}
|
||||
|
||||
if (!isActive()) {
|
||||
// 到期后只提示一次
|
||||
if (enabled && !expiryLogged && System.currentTimeMillis() >= windowEnd) {
|
||||
expiryLogged = true;
|
||||
plugin.getLogger().info("[维护模式] 维护窗口已到期(" + fmt(windowEnd) + "),自动结束");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
String name = e.getName();
|
||||
if (isWhitelisted(name)) return;
|
||||
if (opBypass && isOp(name)) {
|
||||
plugin.getLogger().info("[维护模式] 放行OP: " + name);
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 无感化拦截:在异步预登录阶段直接断开,客户端只看到自定义提示,
|
||||
// 服务端不会为他分配世界/区块,也不产生多余的网络往返
|
||||
e.disallow(AsyncPlayerPreLoginEvent.Result.KICK_BANNED, buildMessage(name));
|
||||
plugin.getLogger().info("[维护模式] 已拦截未在白名单的玩家: " + name
|
||||
+ "(当前白名单 " + whitelistLower.size() + " 人)");
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onJoin(PlayerJoinEvent e) {
|
||||
try {
|
||||
reloadIfChanged();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onQuit(PlayerQuitEvent e) {
|
||||
try {
|
||||
reloadIfChanged();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 提示消息 ====================
|
||||
|
||||
private Component buildMessage(String playerName) {
|
||||
String msg = this.rawMessage;
|
||||
long s = this.windowStart;
|
||||
long e = this.windowEnd;
|
||||
msg = replaceVar(msg, new String[]{"username", "用户名", "用户", "player", "玩家名",
|
||||
"玩家", "name", "昵称"}, playerName);
|
||||
msg = replaceVar(msg, new String[]{"starttime", "start_time", "开始时间",
|
||||
"起始时间", "start"}, fmt(s));
|
||||
msg = replaceVar(msg, new String[]{"endtime", "end_time", "结束时间",
|
||||
"截止时间", "end"}, fmt(e));
|
||||
return format(msg);
|
||||
}
|
||||
|
||||
// ==================== 占位符定界符 ====================
|
||||
|
||||
/**
|
||||
* 占位符可用的定界符对:中英文小括号、中括号、花括号,外加 %…% 与 <…>。
|
||||
* 例:{username} [username] (username) 【用户】 (用户) {username} [username]
|
||||
* 「玩家名」 『开始时间』 %endtime% <结束时间>
|
||||
* JSON 模式会跳过花括号 {} 这一对——JSON 对象结构本身就是花括号,避免不必要的冲突。
|
||||
*/
|
||||
private static final String[][] PLACEHOLDER_DELIMS = {
|
||||
{"{", "}"},
|
||||
{"[", "]"},
|
||||
{"(", ")"},
|
||||
{"【", "】"},
|
||||
{"(", ")"},
|
||||
{"{", "}"},
|
||||
{"[", "]"},
|
||||
{"「", "」"},
|
||||
{"『", "』"},
|
||||
{"%", "%"},
|
||||
{"<", ">"}
|
||||
};
|
||||
|
||||
/** 是否 Legacy 色码字符(0-9a-fk-or,大小写不敏感,含 hex 的 x) */
|
||||
private static boolean isLegacyCodeChar(char c) {
|
||||
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
||||
|| "kKlLoOmMnNrRxX".indexOf(c) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 防御性剥离:去掉首尾空白与 Legacy 色码(&c / §c / &x&R&R&R&R&R&R)。
|
||||
* headDeco(可为 null)收集头部被剥离的色码原串,JSON 解析成功后回贴到组件上;
|
||||
* 尾部色码只剥离不收集——它后面已无文本,应用它没有意义。
|
||||
* 这层剥离专门解决「&c&l 包着 JSON → 判不出 JSON → 原样吐源码给玩家」的冲突。
|
||||
*/
|
||||
private static String stripDecoration(String s, StringBuilder headDeco) {
|
||||
if (s == null) return "";
|
||||
int b = 0, e = s.length();
|
||||
while (b < e) {
|
||||
char c = s.charAt(b);
|
||||
if (Character.isWhitespace(c)) {
|
||||
b++;
|
||||
continue;
|
||||
}
|
||||
if ((c == '&' || c == '§') && b + 1 < e && isLegacyCodeChar(s.charAt(b + 1))) {
|
||||
if (headDeco != null) headDeco.append(c).append(s.charAt(b + 1));
|
||||
b += 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
while (e > b) {
|
||||
char last = s.charAt(e - 1);
|
||||
if (Character.isWhitespace(last)) {
|
||||
e--;
|
||||
continue;
|
||||
}
|
||||
if (e - 2 >= b && (s.charAt(e - 2) == '&' || s.charAt(e - 2) == '§')
|
||||
&& isLegacyCodeChar(last)) {
|
||||
e -= 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return s.substring(b, e);
|
||||
}
|
||||
|
||||
/** Legacy 色码字符 → Adventure 命名色(0-9a-f),未知字符返回 null */
|
||||
private static NamedTextColor legacyColor(char c) {
|
||||
switch (c) {
|
||||
case '0': return NamedTextColor.BLACK;
|
||||
case '1': return NamedTextColor.DARK_BLUE;
|
||||
case '2': return NamedTextColor.DARK_GREEN;
|
||||
case '3': return NamedTextColor.DARK_AQUA;
|
||||
case '4': return NamedTextColor.DARK_RED;
|
||||
case '5': return NamedTextColor.DARK_PURPLE;
|
||||
case '6': return NamedTextColor.GOLD;
|
||||
case '7': return NamedTextColor.GRAY;
|
||||
case '8': return NamedTextColor.DARK_GRAY;
|
||||
case '9': return NamedTextColor.BLUE;
|
||||
case 'a': return NamedTextColor.GREEN;
|
||||
case 'b': return NamedTextColor.AQUA;
|
||||
case 'c': return NamedTextColor.RED;
|
||||
case 'd': return NamedTextColor.LIGHT_PURPLE;
|
||||
case 'e': return NamedTextColor.YELLOW;
|
||||
case 'f': return NamedTextColor.WHITE;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把剥离出来的前置色码(&c&l / §c§l / &x&R…)贴回解析好的组件:
|
||||
* 包一层空组件承载,色码只是「默认样式」,JSON 片段里显式写了颜色/样式的仍然优先。
|
||||
*/
|
||||
private static Component applyDecoration(Component c, String deco) {
|
||||
if (c == null) return Component.empty();
|
||||
if (deco == null || deco.isEmpty()) return c;
|
||||
Component out = Component.empty();
|
||||
for (int i = 0; i + 1 < deco.length(); i++) {
|
||||
char sep = deco.charAt(i);
|
||||
if (sep != '&' && sep != '§') continue;
|
||||
char code = Character.toLowerCase(deco.charAt(i + 1));
|
||||
if (code == 'x') {
|
||||
// 六位十六进制:&x&R&R&R&R&R&R(成对色码,只吃 hex 数字)
|
||||
StringBuilder hex = new StringBuilder();
|
||||
int j = i + 2;
|
||||
while (j + 1 < deco.length()
|
||||
&& (deco.charAt(j) == '&' || deco.charAt(j) == '§')
|
||||
&& isLegacyCodeChar(deco.charAt(j + 1))) {
|
||||
char h = Character.toLowerCase(deco.charAt(j + 1));
|
||||
if (h < '0' || (h > '9' && h < 'a') || h > 'f') break;
|
||||
hex.append(h);
|
||||
j += 2;
|
||||
}
|
||||
if (hex.length() == 6) {
|
||||
TextColor col = TextColor.fromHexString("#" + hex);
|
||||
if (col != null) out = out.color(col);
|
||||
i = j - 2;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ((code >= '0' && code <= '9') || (code >= 'a' && code <= 'f')) {
|
||||
NamedTextColor col = legacyColor(code);
|
||||
if (col != null) out = out.color(col);
|
||||
} else if (code == 'l') {
|
||||
out = out.decorate(TextDecoration.BOLD);
|
||||
} else if (code == 'o') {
|
||||
out = out.decorate(TextDecoration.ITALIC);
|
||||
} else if (code == 'n') {
|
||||
out = out.decorate(TextDecoration.UNDERLINED);
|
||||
} else if (code == 'm') {
|
||||
out = out.decorate(TextDecoration.STRIKETHROUGH);
|
||||
} else if (code == 'k') {
|
||||
out = out.decorate(TextDecoration.OBFUSCATED);
|
||||
} else if (code == 'r') {
|
||||
out = out.color(null)
|
||||
.decoration(TextDecoration.BOLD, TextDecoration.State.FALSE)
|
||||
.decoration(TextDecoration.ITALIC, TextDecoration.State.FALSE)
|
||||
.decoration(TextDecoration.UNDERLINED, TextDecoration.State.FALSE)
|
||||
.decoration(TextDecoration.STRIKETHROUGH, TextDecoration.State.FALSE)
|
||||
.decoration(TextDecoration.OBFUSCATED, TextDecoration.State.FALSE);
|
||||
}
|
||||
}
|
||||
return out.append(c);
|
||||
}
|
||||
|
||||
/** 解析 JSON 文本组件;失败返回 null(不抛异常) */
|
||||
private static Component tryParseJson(String json) {
|
||||
try {
|
||||
return GsonComponentSerializer.gson().deserialize(json);
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「像 JSON 但没闭合」的防御判定:以 { 或 [ 开头,且含引号后跟冒号的键值特征。
|
||||
* 普通文本([公告] 他说你好)不含该特征,挡在外面,不会被误修。
|
||||
*/
|
||||
private static boolean looksRepairableJson(String s) {
|
||||
if (s == null || s.length() < 2) return false;
|
||||
char f = s.charAt(0);
|
||||
return (f == '{' || f == '[') && s.indexOf("\":") >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尽力修补残缺 JSON:引号数为奇数则补一个闭合引号,再按括号栈补齐右括号。
|
||||
* 只在原解析失败后调用;修不好就返回原串(调用方退回纯文本渲染)。
|
||||
*/
|
||||
private static String repairJson(String s) {
|
||||
String out = s;
|
||||
int quotes = 0;
|
||||
for (int i = 0; i < out.length(); i++) {
|
||||
if (out.charAt(i) == '"') quotes++;
|
||||
}
|
||||
if (quotes % 2 == 1) out = out + '"';
|
||||
StringBuilder stack = new StringBuilder();
|
||||
boolean inStr = false;
|
||||
for (int i = 0; i < out.length(); i++) {
|
||||
char c = out.charAt(i);
|
||||
if (c == '"' && (i == 0 || out.charAt(i - 1) != '\\')) inStr = !inStr;
|
||||
if (inStr) continue;
|
||||
if (c == '{') stack.append('}');
|
||||
else if (c == '[') stack.append(']');
|
||||
else if (c == '}' || c == ']') {
|
||||
if (stack.length() > 0 && stack.charAt(stack.length() - 1) == c) {
|
||||
stack.deleteCharAt(stack.length() - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int i = stack.length() - 1; i >= 0; i--) out = out + stack.charAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 占位符替换,定界符见 PLACEHOLDER_DELIMS(中英文小/中/大括号、%…%、<…>)。
|
||||
* JSON 消息(含 &c&l 包着 JSON 的情形):跳过花括号 {}、不做裸子串替换——
|
||||
* 花括号是 JSON 对象结构本身,裸替换会吃掉正文里的 end/start/player 等英文词
|
||||
* ("end soon" -> "2026-09-28 10:00 soon")。JSON 消息里请用 [username]、(用户)、%username% 等形式。
|
||||
* 普通 &c 文本:全部定界符 + 裸替换都生效({username}、username 均可)。
|
||||
* 「标签+定界符」混合写法(开始时间[starttime]):只替换定界符占位符、保留标签文字,
|
||||
* 避免标签被裸替换二次吃掉、渲染成双份时间。
|
||||
*/
|
||||
private static String replaceVar(String msg, String[] keys, String value) {
|
||||
if (msg == null || msg.isEmpty()) return msg;
|
||||
String v = value == null ? "" : value;
|
||||
boolean jsonMode = looksLikeJson(msg);
|
||||
// 「标签+定界符」混合写法防御:如「开始时间[starttime],结束时间:{endtime}」。
|
||||
// 只要本组任一 key 已以定界符形式出现,就说明用户写的是「标签文字 + 占位符」,
|
||||
// 此时整组跳过裸子串替换、保留 开始时间/结束时间 这类标签原文;
|
||||
// 否则标签会被二次替换成时间,渲染成「时间 时间」双份连排。
|
||||
boolean delimHit = false;
|
||||
for (String k : keys) {
|
||||
for (String[] d : PLACEHOLDER_DELIMS) {
|
||||
if (jsonMode && d[0].equals("{")) continue;
|
||||
if (msg.contains(d[0] + k + d[1])) {
|
||||
delimHit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (delimHit) break;
|
||||
}
|
||||
for (String k : keys) {
|
||||
for (String[] d : PLACEHOLDER_DELIMS) {
|
||||
if (jsonMode && d[0].equals("{")) continue; // JSON 跳过花括号,避免与 JSON 结构冲突
|
||||
msg = msg.replace(d[0] + k + d[1], v);
|
||||
}
|
||||
if (!jsonMode && !delimHit) msg = msg.replace(k, v);
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 整段是否为 JSON 文本组件:形如 {...} 或 [...],且含双引号。
|
||||
* 先剥离首尾色码与空白再判定——&c&l 包着 JSON 也认得出来(防御层)。
|
||||
* 「含双引号」用于把 [玩家名]、[公告] 这类普通文本挡在 JSON 分支之外——
|
||||
* Gson 默认宽松解析会把 [玩家名] 当字符串数组吃掉,导致正文被吞。
|
||||
*/
|
||||
private static boolean looksLikeJson(String s) {
|
||||
if (s == null) return false;
|
||||
String t = stripDecoration(s, null);
|
||||
int n = t.length();
|
||||
if (n < 2 || t.indexOf('"') < 0) return false;
|
||||
char f = t.charAt(0);
|
||||
char l = t.charAt(n - 1);
|
||||
return (f == '{' && l == '}') || (f == '[' && l == ']');
|
||||
}
|
||||
|
||||
/** JSON 内的 <br> 变体(<br> <br/> <br /> </br>,大小写不敏感) */
|
||||
private static final Pattern BR_IN_JSON_PAT = Pattern.compile("(?i)</?br\\s*/?>");
|
||||
|
||||
/**
|
||||
* 把 JSON 文本里的字面 <br> 换成 JSON 转义换行(反斜杠 + n 两字符)。
|
||||
* 必须是两字符序列:真实换行在 JSON 字符串里非法,写进去整段就解析失败了。
|
||||
*/
|
||||
private static String replaceBrInJson(String json) {
|
||||
return BR_IN_JSON_PAT.matcher(json).replaceAll("\\\\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息格式化:
|
||||
* 0) 防御层:剥掉首尾色码/空白再判定 JSON;残缺 JSON 尽力修补——
|
||||
* 绝不把 JSON 源码原样吐给玩家(剥掉的色码如 &c&l 作为默认样式贴回结果)
|
||||
* 1) 整段是 JSON(对象 {...} 或顶层数组 [...],后者是 tellraw 常见写法)
|
||||
* → 按文本组件解析(支持 text/color/extra 等);字面 <br> 会先换成 JSON 转义换行
|
||||
* 2) 否则处理 <br> 与字面 \n 换行、& 颜色码与 § 颜色码
|
||||
*/
|
||||
private static Component format(String msg) {
|
||||
if (msg == null) msg = "";
|
||||
// 防御层:先剥掉包在外面的 Legacy 色码(&c&l / §c§l 等)再判定,
|
||||
// 否则「&c&l 包着 JSON」首字符是 & → 判不出 JSON → JSON 源码原样吐给玩家。
|
||||
StringBuilder headDeco = new StringBuilder();
|
||||
String core = stripDecoration(msg.trim(), headDeco);
|
||||
if (looksLikeJson(core) || looksRepairableJson(core)) {
|
||||
// ★ JSON 字符串里的字面 <br>/<br/> 同样要变成换行,否则标签会原样显示给玩家。
|
||||
// Gson 只认 JSON 转义(反斜杠 + n),所以替换成两字符序列,而不是真实换行
|
||||
// (真实换行在 JSON 字符串里是非法字符,会让整段解析失败退回纯文本)。
|
||||
Component parsed = tryParseJson(replaceBrInJson(core));
|
||||
if (parsed == null) {
|
||||
// 残缺 JSON(缺引号/右括号)→ 尽力补齐再试一次,同样是为了不原样吐源码
|
||||
String fixed = repairJson(core);
|
||||
if (!fixed.equals(core)) parsed = tryParseJson(replaceBrInJson(fixed));
|
||||
}
|
||||
if (parsed != null) return applyDecoration(parsed, headDeco.toString());
|
||||
}
|
||||
String s = msg;
|
||||
s = s.replace("<br/>", "\n").replace("<br />", "\n").replace("<br>", "\n");
|
||||
s = s.replace("</br>", "\n").replace("<BR>", "\n").replace("<Br>", "\n");
|
||||
s = s.replace("\\n", "\n");
|
||||
s = AMP_COLOR_PAT.matcher(s).replaceAll("§$1");
|
||||
|
||||
String[] lines = s.split("\n", -1);
|
||||
Component out = Component.empty();
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
if (i > 0) out = out.append(Component.newline());
|
||||
out = out.append(LegacyComponentSerializer.legacySection().deserialize(lines[i]));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String fmt(long ms) {
|
||||
try {
|
||||
return new SimpleDateFormat("yyyy-MM-dd HH:mm").format(new Date(ms));
|
||||
} catch (Exception e) {
|
||||
return String.valueOf(ms);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 工具 ====================
|
||||
|
||||
private boolean isOp(String name) {
|
||||
Boolean cached = opCache.get(name);
|
||||
if (cached != null) return cached;
|
||||
boolean r = false;
|
||||
try {
|
||||
OfflinePlayer p = Bukkit.getOfflinePlayer(name);
|
||||
r = p.isOp();
|
||||
} catch (Throwable t) {
|
||||
r = false;
|
||||
}
|
||||
opCache.put(name, r);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 段落头:[白名单] / 【白名单】 / 白名单 / ===== 白名单段 ===== / 玩家白名单 / [WHITELIST](含 = 的行一律不是段落头) */
|
||||
private static boolean isWhitelistHeader(String line) {
|
||||
String s = line.trim();
|
||||
if (s.isEmpty()) return false;
|
||||
// ★ 注释形式的段标题也算:用户常把「[白名单]」行删掉,只留下「# ===== 白名单段 =====」这条注释。
|
||||
// 先剥掉 # /// 注释前缀再判;键值对类注释(含 =)仍被下面的硬门槛挡住,不会误判成段落头。
|
||||
while (s.startsWith("#") || s.startsWith("//")) {
|
||||
s = s.substring(s.startsWith("//") ? 2 : 1).trim();
|
||||
}
|
||||
if (s.isEmpty()) return false;
|
||||
// ★ 硬门槛:先剥掉首尾装饰符号(=====、----、::: 等),再看中间是否还剩「=」。
|
||||
// 剩下的 = 说明这是控制段的键值对(如「挂机白名单 = xxx」),绝不是段落头;
|
||||
// 只有装饰等号的 =====白名单段===== 剥完就干净了,不会被误杀。
|
||||
// 没有这道门槛时,控制键一旦被误判成段落头,后续所有行都滑进白名单段,
|
||||
// 「启用 = true」就读不到 → 维护开关直接失效。
|
||||
String body = s.replaceAll("^[=\\-_::·*#\\s]+|[=\\-_::·*#\\s]+$", "");
|
||||
if (body.indexOf('=') >= 0) return false;
|
||||
// 剥离各类括号与装饰符号(含全角【】〔〕「」『』()),只留标题正文
|
||||
String stripped = s.replaceAll("[\\[\\]\\u3010\\u3011\\u3014\\u3015\\u300c\\u300d\\u300e\\u300f"
|
||||
+ "\\uff08\\uff09()\\-=_::·*#\\s]+", "");
|
||||
String low = stripped.toLowerCase(Locale.ROOT);
|
||||
// 精确匹配常见写法;再兜底 endsWith 兼容「玩家白名单」「ALLOWLIST」这类变体标题
|
||||
//(有了上面的 = 门槛,endsWith 已经不会再吞掉控制段键值对)
|
||||
return low.equals("白名单") || low.equals("白名单段") || low.equals("白名单列表")
|
||||
|| low.equals("允许名单") || low.equals("whitelist") || low.equals("allowlist")
|
||||
|| low.endsWith("白名单") || low.endsWith("whitelist") || low.endsWith("allowlist");
|
||||
}
|
||||
|
||||
// ==================== 配置文本解析(static,便于单测) ====================
|
||||
|
||||
/** parseConfig 的结果:控制段键值对 + 白名单条目 + 是否见过 [白名单] 段标题 */
|
||||
static final class ParsedConfig {
|
||||
final Map<String, String> ctl;
|
||||
final Set<String> wl;
|
||||
final boolean sawWhiteHeader;
|
||||
|
||||
ParsedConfig(Map<String, String> ctl, Set<String> wl, boolean sawWhiteHeader) {
|
||||
this.ctl = ctl;
|
||||
this.wl = wl;
|
||||
this.sawWhiteHeader = sawWhiteHeader;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析配置文本。三层兜底,防止管理员把自己关在门外:
|
||||
* 1) 段标题判断在注释跳过之前——「# ===== 白名单段 =====」这种注释形式的标题也认
|
||||
* 2) 段外的无键值行(既没有 = 也没有 :)按玩家名收录——标题行被删时靠它救
|
||||
* 3) 条目先清洗零宽字符 / 行内注释 / 列表前缀再收录,避免肉眼看着对却匹配不上
|
||||
*/
|
||||
static ParsedConfig parseConfig(String text, Logger log) {
|
||||
Map<String, String> ctl = new LinkedHashMap<>();
|
||||
Set<String> wl = new LinkedHashSet<>();
|
||||
boolean inWhite = false;
|
||||
boolean sawHeader = false;
|
||||
for (String rawLine : text.split("\n")) {
|
||||
String line = rawLine.trim();
|
||||
if (line.isEmpty()) continue;
|
||||
if (isWhitelistHeader(line)) {
|
||||
inWhite = true;
|
||||
sawHeader = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("#") || line.startsWith("//")) continue;
|
||||
if (inWhite) {
|
||||
addWhitelistEntry(wl, line, log, null);
|
||||
} else {
|
||||
int eq = line.indexOf('=');
|
||||
if (eq < 0) eq = line.indexOf(':');
|
||||
if (eq < 0) {
|
||||
addWhitelistEntry(wl, line, log,
|
||||
"该行不在 [白名单] 段内也不是键值对,已按玩家名兜底收录");
|
||||
continue;
|
||||
}
|
||||
String k = normKey(line.substring(0, eq));
|
||||
if (k.isEmpty()) continue;
|
||||
ctl.put(k, line.substring(eq + 1).trim());
|
||||
}
|
||||
}
|
||||
if (!sawHeader && !wl.isEmpty()) {
|
||||
warn(log, "未识别到 [白名单] 段标题,已按「无键值=玩家名」兜底收录 "
|
||||
+ wl.size() + " 个条目");
|
||||
}
|
||||
return new ParsedConfig(ctl, wl, sawHeader);
|
||||
}
|
||||
|
||||
/** 清洗并收录一条白名单条目(note 为附加告警说明,可为 null) */
|
||||
private static void addWhitelistEntry(Set<String> wl, String raw, Logger log, String note) {
|
||||
String s = stripInvisible(raw);
|
||||
if (!s.equals(raw)) {
|
||||
warn(log, "白名单条目含不可见字符(零宽/全角空格/不间断空格),已清洗: ["
|
||||
+ raw + "] -> [" + s + "]");
|
||||
}
|
||||
int c = indexOfInlineComment(s);
|
||||
if (c >= 0) {
|
||||
warn(log, "白名单条目的行内注释已忽略: " + s.substring(c).trim());
|
||||
s = s.substring(0, c).trim();
|
||||
}
|
||||
// 列表装饰前缀:- > * · •(合法 MC 名不会以这些字符开头)
|
||||
String noDecor = s.replaceFirst("^[-*>\\u00b7\\u2022]\\s+", "");
|
||||
if (!noDecor.equals(s)) {
|
||||
warn(log, "白名单条目前缀符号已忽略: [" + s + "] -> [" + noDecor + "]");
|
||||
s = noDecor;
|
||||
}
|
||||
s = s.trim();
|
||||
if (s.isEmpty()) return;
|
||||
if (note != null) warn(log, note + ": " + raw);
|
||||
// 仍坚持「一行一个玩家名」:整行收录,但格式不规范必须能被看见
|
||||
if (s.indexOf(',') >= 0 || s.indexOf('\uFF0C') >= 0 || s.indexOf('\u3001') >= 0
|
||||
|| s.indexOf(';') >= 0 || s.indexOf('\uFF1B') >= 0 || s.indexOf(' ') >= 0
|
||||
|| s.indexOf('\u3000') >= 0 || s.indexOf('\t') >= 0) {
|
||||
warn(log, "白名单条目不符合「一行一个玩家名」,已按整行收录: " + s);
|
||||
}
|
||||
wl.add(s);
|
||||
}
|
||||
|
||||
/** 去掉肉眼看不见却会让匹配失败的字符:零宽/BOM/不间断空格/全角空格 */
|
||||
private static String stripInvisible(String s) {
|
||||
if (s == null) return null;
|
||||
return s.replaceAll("[\\u200B-\\u200D\\u2060\\uFEFF\\u00A0\\u3000]", "");
|
||||
}
|
||||
|
||||
/** 行内注释起点(# / # / //),返回 -1 表示没有 */
|
||||
private static int indexOfInlineComment(String s) {
|
||||
int h = s.indexOf('#');
|
||||
if (h < 0) h = s.indexOf('\uFF03');
|
||||
if (h < 0) h = s.indexOf("//");
|
||||
return h <= 0 ? -1 : h;
|
||||
}
|
||||
|
||||
private static void warn(Logger log, String msg) {
|
||||
if (log != null) log.warning("[维护模式] " + msg);
|
||||
}
|
||||
|
||||
/** 布尔词识别(与 toBool 口径一致),用于把 flase / ture 这类拼写错误提示出来 */
|
||||
private static boolean isBoolWord(String v) {
|
||||
if (v == null) return false;
|
||||
String s = v.trim().toLowerCase(Locale.ROOT);
|
||||
if (s.isEmpty()) return false;
|
||||
return s.equals("true") || s.equals("yes") || s.equals("y") || s.equals("on")
|
||||
|| s.equals("1") || s.equals("是") || s.equals("开") || s.equals("开启") || s.equals("启用")
|
||||
|| s.equals("false") || s.equals("no") || s.equals("n") || s.equals("off")
|
||||
|| s.equals("0") || s.equals("否") || s.equals("关") || s.equals("关闭") || s.equals("停用");
|
||||
}
|
||||
|
||||
/** 白名单条目摘要(最多 20 个),日志里直接对账用 */
|
||||
private static String summarize(Set<String> names) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
int i = 0;
|
||||
for (String n : names) {
|
||||
if (i == 20) {
|
||||
sb.append(", …共").append(names.size()).append("人");
|
||||
return sb.toString();
|
||||
}
|
||||
if (i++ > 0) sb.append(", ");
|
||||
sb.append(n);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String normKey(String k) {
|
||||
if (k == null) return "";
|
||||
return k.trim().toLowerCase(Locale.ROOT).replace(" ", "").replace("_", "").replace(" ", "");
|
||||
}
|
||||
|
||||
private static String pick(Map<String, String> ctl, String... keys) {
|
||||
for (String k : keys) {
|
||||
String v = ctl.get(normKey(k));
|
||||
if (v != null && !v.trim().isEmpty()) return v.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean toBool(String v, boolean def) {
|
||||
if (v == null) return def;
|
||||
String s = v.trim().toLowerCase(Locale.ROOT);
|
||||
if (s.isEmpty()) return def;
|
||||
return s.equals("true") || s.equals("yes") || s.equals("y") || s.equals("on")
|
||||
|| s.equals("1") || s.equals("是") || s.equals("开") || s.equals("开启") || s.equals("启用");
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间解析,支持:
|
||||
* - 10位秒级时间戳 / 13位毫秒时间戳
|
||||
* - 2026-09-28 20:00 / 2026/9/28 20:00:33 / 2026.09.28
|
||||
* - 2026年9月28日20时30分 / 2026年9月28日 20:30
|
||||
* - 只写 20:00(今天该时刻)
|
||||
* 解析失败返回 null
|
||||
*/
|
||||
static Long parseTime(String raw) {
|
||||
if (raw == null) return null;
|
||||
String s = raw.trim().replace("\"", "");
|
||||
if (s.isEmpty()) return null;
|
||||
if (s.matches("\\d{10}")) return Long.parseLong(s) * 1000L;
|
||||
if (s.matches("\\d{13}")) return Long.parseLong(s);
|
||||
|
||||
List<Long> n = new ArrayList<>();
|
||||
Matcher m = NUM_PAT.matcher(s);
|
||||
while (m.find()) {
|
||||
try {
|
||||
n.add(Long.parseLong(m.group()));
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
}
|
||||
if (n.isEmpty()) return null;
|
||||
|
||||
String first = String.valueOf(n.get(0));
|
||||
if (first.length() == 10) return n.get(0) * 1000L;
|
||||
if (first.length() == 13) return n.get(0);
|
||||
|
||||
if (n.size() == 2 && n.get(0) <= 23 && n.get(1) <= 59) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
return now.withHour(n.get(0).intValue()).withMinute(n.get(1).intValue())
|
||||
.withSecond(0).withNano(0)
|
||||
.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
|
||||
}
|
||||
if (n.size() < 3) return null;
|
||||
|
||||
int y = n.get(0).intValue();
|
||||
int mo = n.get(1).intValue();
|
||||
int d = n.get(2).intValue();
|
||||
int h = n.size() > 3 ? n.get(3).intValue() : 0;
|
||||
int mi = n.size() > 4 ? n.get(4).intValue() : 0;
|
||||
int se = n.size() > 5 ? n.get(5).intValue() : 0;
|
||||
if (y < 1970 || y > 2100 || mo < 1 || mo > 12 || d < 1 || d > 31
|
||||
|| h > 23 || mi > 59 || se > 59) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDateTime.of(y, mo, d, h, mi, se)
|
||||
.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 时长解析:24 / 24h / 12小时 / 90分钟 / 2天,默认单位小时 */
|
||||
static Long parseDuration(String raw) {
|
||||
if (raw == null) return null;
|
||||
String s = raw.trim().toLowerCase(Locale.ROOT);
|
||||
if (s.isEmpty()) return null;
|
||||
Matcher m = DUR_PAT.matcher(s);
|
||||
if (!m.find()) return null;
|
||||
double v;
|
||||
try {
|
||||
v = Double.parseDouble(m.group(1));
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
String unit = m.group(2) == null ? "" : m.group(2);
|
||||
long ms;
|
||||
if (unit.contains("毫秒")) ms = 1L;
|
||||
else if (unit.startsWith("s") || unit.contains("秒")) ms = 1000L;
|
||||
else if (unit.startsWith("m") || unit.contains("分")) ms = 60_000L;
|
||||
else if (unit.startsWith("d") || unit.contains("天")) ms = 86_400_000L;
|
||||
else ms = 3_600_000L;
|
||||
long r = (long) (v * ms);
|
||||
return r <= 0 ? null : r;
|
||||
}
|
||||
|
||||
/** 优先按 UTF-8 严格解码,失败则按 GBK(Windows 记事本默认编码) */
|
||||
private static String decode(byte[] bytes) {
|
||||
if (bytes == null || bytes.length == 0) return "";
|
||||
try {
|
||||
CharsetDecoder dec = StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||
dec.decode(ByteBuffer.wrap(bytes));
|
||||
return new String(bytes, StandardCharsets.UTF_8);
|
||||
} catch (CharacterCodingException e) {
|
||||
try {
|
||||
return new String(bytes, Charset.forName("GBK"));
|
||||
} catch (Exception e2) {
|
||||
return new String(bytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/**
|
||||
* 多源在线翻译工具 —— 将MC英文材料名翻译为中文。
|
||||
* 优先使用国内接口(有道/简心),失败后降级到Google。
|
||||
* 结果缓存避免重复请求。
|
||||
*/
|
||||
public class MaterialTranslator {
|
||||
|
||||
private static final ConcurrentHashMap<String, String> cache = new ConcurrentHashMap<>();
|
||||
private static final ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
private static final HttpClient http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(3))
|
||||
.build();
|
||||
|
||||
// 翻译源优先级:国内接口优先
|
||||
private static final List<TranslationSource> SOURCES = Arrays.asList(
|
||||
TranslationSource.YOUDAO,
|
||||
TranslationSource.JIANXIN,
|
||||
TranslationSource.GOOGLE
|
||||
);
|
||||
|
||||
/**
|
||||
* 翻译源枚举
|
||||
*/
|
||||
private enum TranslationSource {
|
||||
YOUDAO("有道翻译"),
|
||||
JIANXIN("简心翻译"),
|
||||
GOOGLE("Google翻译");
|
||||
|
||||
final String name;
|
||||
|
||||
TranslationSource(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将材料名(如 GRASS_BLOCK)转为可读英文(Grass Block)
|
||||
*/
|
||||
public static String toReadable(String materialName) {
|
||||
if (materialName == null || materialName.isEmpty()) return "";
|
||||
return Arrays.stream(materialName.split("_"))
|
||||
.map(w -> w.substring(0, 1).toUpperCase() + w.substring(1).toLowerCase())
|
||||
.reduce((a, b) -> a + " " + b)
|
||||
.orElse(materialName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步翻译单个材料名(有缓存则直接返回)。
|
||||
* 优先走 MC 官方 zh_cn.json 翻译,查不到才走机翻。
|
||||
*/
|
||||
public static String translate(String materialName) {
|
||||
if (materialName == null || materialName.isEmpty()) return "";
|
||||
String key = materialName.toUpperCase();
|
||||
|
||||
// 缓存命中
|
||||
if (cache.containsKey(key)) {
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
// 1. 官方翻译(基于原始 Material 名 GRASS_BLOCK → block.minecraft.grass_block)
|
||||
String official = OfficialTranslations.lookUp(materialName);
|
||||
if (official != null && !official.isEmpty()) {
|
||||
cache.put(key, official);
|
||||
return official;
|
||||
}
|
||||
|
||||
// 2. 机翻兜底
|
||||
String readable = toReadable(key);
|
||||
String zh = translateWithFallback(readable);
|
||||
if (zh != null && !zh.isEmpty()) {
|
||||
cache.put(key, zh);
|
||||
return zh;
|
||||
}
|
||||
|
||||
return readable; // fallback 可读英文
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量异步翻译,返回 future map
|
||||
*/
|
||||
public static CompletableFuture<Map<String, String>> translateBatch(Collection<String> materials) {
|
||||
Set<String> unique = new LinkedHashSet<>();
|
||||
for (String m : materials) {
|
||||
if (m != null && !m.isEmpty() && !cache.containsKey(m.toUpperCase())) {
|
||||
unique.add(m);
|
||||
}
|
||||
}
|
||||
if (unique.isEmpty()) {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
for (String m : materials) {
|
||||
if (m != null) result.put(m, cache.getOrDefault(m.toUpperCase(), toReadable(m)));
|
||||
}
|
||||
return CompletableFuture.completedFuture(result);
|
||||
}
|
||||
|
||||
List<CompletableFuture<Void>> futures = new ArrayList<>();
|
||||
for (String m : unique) {
|
||||
futures.add(CompletableFuture.runAsync(() -> translate(m), executor));
|
||||
}
|
||||
|
||||
return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
|
||||
.thenApply(v -> {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
for (String m : materials) {
|
||||
if (m != null) {
|
||||
result.put(m, cache.getOrDefault(m.toUpperCase(), toReadable(m)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 多源翻译,按优先级尝试,失败自动降级
|
||||
*/
|
||||
private static String translateWithFallback(String text) {
|
||||
for (TranslationSource source : SOURCES) {
|
||||
try {
|
||||
String result = null;
|
||||
switch (source) {
|
||||
case YOUDAO:
|
||||
result = translateViaYoudao(text);
|
||||
break;
|
||||
case JIANXIN:
|
||||
result = translateViaJianxin(text);
|
||||
break;
|
||||
case GOOGLE:
|
||||
result = translateViaGoogle(text);
|
||||
break;
|
||||
}
|
||||
if (result != null && !result.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Bukkit.getLogger().warning("[翻译] 机翻 " + source.name + " 异常: " + e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== 翻译源实现 ====================
|
||||
|
||||
/**
|
||||
* 有道翻译API
|
||||
* URL: https://v.api.aa1.cn/api/api-fanyi-yd/index.php
|
||||
* 参数: msg=翻译内容, type=1(中→英) / 2(英→中)
|
||||
*/
|
||||
private static String translateViaYoudao(String text) {
|
||||
try {
|
||||
String encoded = URLEncoder.encode(text, StandardCharsets.UTF_8);
|
||||
// type=2 表示英→中
|
||||
String url = "https://v.api.aa1.cn/api/api-fanyi-yd/index.php?msg=" + encoded + "&type=2";
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.timeout(Duration.ofSeconds(3))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() == 200) {
|
||||
// 返回: {"type":"中英互译","desc":"中文翻译英文","text":"翻译结果"}
|
||||
return parseJsonField(resp.body(), "text");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 静默降级
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 简心翻译API
|
||||
* URL: https://api.qvqa.cn/api/fanyi
|
||||
* 参数: text=翻译内容, source=en, target=zh
|
||||
*/
|
||||
private static String translateViaJianxin(String text) {
|
||||
try {
|
||||
String encoded = URLEncoder.encode(text, StandardCharsets.UTF_8);
|
||||
String url = "https://api.qvqa.cn/api/fanyi?text=" + encoded + "&source=en&target=zh";
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.timeout(Duration.ofSeconds(3))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() == 200) {
|
||||
// 返回: {"code":200,"message":"OK","data":{"sourceText":"...","targetText":"翻译结果",...}}
|
||||
return parseNestedJsonField(resp.body(), "data", "targetText");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 静默降级
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Translate 免费接口
|
||||
*/
|
||||
private static String translateViaGoogle(String text) {
|
||||
try {
|
||||
String encoded = URLEncoder.encode(text, StandardCharsets.UTF_8);
|
||||
String url = "https://clients5.google.com/translate_a/single"
|
||||
+ "?client=gtx&sl=en&tl=zh-CN&dt=t&q=" + encoded;
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.timeout(Duration.ofSeconds(3))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() == 200) {
|
||||
// 响应格式: [["翻译结果","原文",...],...]
|
||||
String body = resp.body();
|
||||
int firstQuote = body.indexOf("[[\"");
|
||||
if (firstQuote >= 0) {
|
||||
int endQuote = body.indexOf("\"", firstQuote + 3);
|
||||
if (endQuote > firstQuote) {
|
||||
return body.substring(firstQuote + 3, endQuote);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 静默降级
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== JSON解析工具 ====================
|
||||
|
||||
/**
|
||||
* 简单JSON字段解析(单层)
|
||||
* 从 {"key":"value"} 中提取 value
|
||||
*/
|
||||
private static String parseJsonField(String json, String field) {
|
||||
try {
|
||||
String pattern = "\"" + field + "\"";
|
||||
int idx = json.indexOf(pattern);
|
||||
if (idx < 0) return null;
|
||||
|
||||
// 找到冒号后的引号
|
||||
int colon = json.indexOf(':', idx + pattern.length());
|
||||
if (colon < 0) return null;
|
||||
|
||||
int quoteStart = json.indexOf('"', colon + 1);
|
||||
if (quoteStart < 0) return null;
|
||||
|
||||
int quoteEnd = json.indexOf('"', quoteStart + 1);
|
||||
if (quoteEnd < 0) return null;
|
||||
|
||||
return json.substring(quoteStart + 1, quoteEnd);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 嵌套JSON字段解析
|
||||
* 从 {"outer":{"inner":"value"}} 中提取 value
|
||||
*/
|
||||
private static String parseNestedJsonField(String json, String outerField, String innerField) {
|
||||
try {
|
||||
// 找到外层对象
|
||||
String outerPattern = "\"" + outerField + "\"";
|
||||
int outerIdx = json.indexOf(outerPattern);
|
||||
if (outerIdx < 0) return null;
|
||||
|
||||
// 找到外层对象的开始大括号
|
||||
int braceStart = json.indexOf('{', outerIdx);
|
||||
if (braceStart < 0) return null;
|
||||
|
||||
// 从外层对象内部解析内层字段
|
||||
int depth = 1;
|
||||
int i = braceStart + 1;
|
||||
while (i < json.length() && depth > 0) {
|
||||
char c = json.charAt(i);
|
||||
if (c == '{') depth++;
|
||||
else if (c == '}') depth--;
|
||||
i++;
|
||||
}
|
||||
String innerJson = json.substring(braceStart, i);
|
||||
return parseJsonField(innerJson, innerField);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理缓存(可选)
|
||||
*/
|
||||
public static void clearCache() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前使用的翻译源信息(调试用)
|
||||
*/
|
||||
public static String getSourceInfo() {
|
||||
StringBuilder sb = new StringBuilder("翻译源优先级: ");
|
||||
for (int i = 0; i < SOURCES.size(); i++) {
|
||||
TranslationSource src = SOURCES.get(i);
|
||||
sb.append(i + 1).append(".").append(src.name);
|
||||
if (i < SOURCES.size() - 1) sb.append(" > ");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -83,18 +83,29 @@ public class MenuManager implements Listener {
|
||||
return items;
|
||||
}
|
||||
|
||||
// ===== 读取菜单.txt =====
|
||||
// ===== 读取菜单.txt(主菜单唯一来源) =====
|
||||
public void loadMenu() {
|
||||
items.clear();
|
||||
|
||||
// 优先菜单.txt,备选menu.txt
|
||||
File f = new File(
|
||||
plugin.getDataFolder(),
|
||||
"菜单.txt");
|
||||
if (!f.exists()) createDefault(f);
|
||||
if (!f.exists()) {
|
||||
File alt = new File(
|
||||
plugin.getDataFolder(),
|
||||
"menu.txt");
|
||||
if (alt.exists()) {
|
||||
f = alt;
|
||||
} else {
|
||||
createDefault(f);
|
||||
}
|
||||
}
|
||||
parseFile(f);
|
||||
plugin.getLogger().info(
|
||||
"[Menu] 加载 "
|
||||
+ items.size()
|
||||
+ " 个菜单项");
|
||||
"[Menu] 加载 " + items.size()
|
||||
+ " 个菜单项 (源: "
|
||||
+ f.getName() + ")");
|
||||
}
|
||||
|
||||
private void parseFile(File f) {
|
||||
@@ -372,9 +383,16 @@ public class MenuManager implements Listener {
|
||||
|
||||
// ===== 保存 =====
|
||||
public void saveMenu() {
|
||||
// 保存到加载源文件
|
||||
File f = new File(
|
||||
plugin.getDataFolder(),
|
||||
"菜单.txt");
|
||||
if (!f.exists()) {
|
||||
File alt = new File(
|
||||
plugin.getDataFolder(),
|
||||
"menu.txt");
|
||||
if (alt.exists()) f = alt;
|
||||
}
|
||||
try (PrintWriter pw = new PrintWriter(
|
||||
new OutputStreamWriter(
|
||||
new FileOutputStream(f),
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import org.bukkit.Bukkit;
|
||||
|
||||
import java.io.File;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* MC 官方中文翻译加载器。<br>
|
||||
* <br>
|
||||
* 工作流程优先级:<br>
|
||||
* 1. 本地缓存 (plugins/Sdf1_login/zh_cn.json) —— 同步加载,开机即用<br>
|
||||
* 2. Mojang API —— 异步下载最新版,写缓存并刷入内存<br>
|
||||
* 3. 全部失败 —— 返回 null,调用者走机翻兜底<br>
|
||||
* <br>
|
||||
* zh_cn.json 键示例:<br>
|
||||
* {@code block.minecraft.grass_block → "草方块"}<br>
|
||||
* {@code item.minecraft.diamond_sword → "钻石剑"}<br>
|
||||
* <br>
|
||||
* 查找策略:直接 key → block.minecraft.* → item.minecraft.*
|
||||
*/
|
||||
public class OfficialTranslations {
|
||||
|
||||
private static final ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
|
||||
private static final Gson gson = new Gson();
|
||||
|
||||
// ---------- 对外 API ----------
|
||||
|
||||
/**
|
||||
* 初始化(在 onEnable 调用)。<br>
|
||||
* 纯同步:先读本地缓存 → 没有则从 Mojang API 下载并写缓存。<br>
|
||||
* 缓存文件由部署脚本随 JAR 一起上传,确保服务器不直连 Mojang 也能加载。
|
||||
*/
|
||||
public static void init(Main plugin) {
|
||||
File cacheFile = new File(plugin.getDataFolder(), "zh_cn.json");
|
||||
|
||||
// 1. 本地缓存 —— 同步加载
|
||||
if (cacheFile.isFile()) {
|
||||
try {
|
||||
String content = Files.readString(cacheFile.toPath(), StandardCharsets.UTF_8);
|
||||
loadFromJson(content);
|
||||
plugin.getLogger().info("[官方翻译] 已加载本地缓存 zh_cn.json(" + map.size() + " 条)");
|
||||
return; // 缓存命中,完毕
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[官方翻译] 本地缓存损坏: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 无缓存或损坏 → 同步从 Mojang API 下载
|
||||
plugin.getLogger().info("[官方翻译] 本地缓存不存在/损坏,从 Mojang API 同步下载...");
|
||||
try {
|
||||
String version = getMinecraftVersion();
|
||||
if (version == null) throw new RuntimeException("无法获取服务器版本号");
|
||||
String json = downloadFromMojang(version);
|
||||
if (json == null) throw new RuntimeException("下载返回空");
|
||||
Files.writeString(cacheFile.toPath(), json, StandardCharsets.UTF_8);
|
||||
loadFromJson(json);
|
||||
plugin.getLogger().info("[官方翻译] Mojang 下载成功(" + map.size() + " 条),已缓存至 " + cacheFile.getName());
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[官方翻译] 同步下载失败: " + e.getMessage() + ",走机翻兜底");
|
||||
plugin.getLogger().log(Level.FINE, "[官方翻译] 同步下载详情", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找官方翻译。<br>
|
||||
* 传入原始 Material 名(如 {@code GRASS_BLOCK}),自动尝试多种 key 模式。
|
||||
*
|
||||
* @param materialName 原始 Material 名(大小写无关)
|
||||
* @return 中文翻译,或 null(未命中)
|
||||
*/
|
||||
public static String lookUp(String materialName) {
|
||||
if (materialName == null || materialName.isEmpty()) return null;
|
||||
String lower = materialName.toLowerCase(Locale.ROOT);
|
||||
|
||||
// 日志:当前 map 状态
|
||||
Bukkit.getLogger().info("[翻译] officialLookUp: " + materialName
|
||||
+ " | map已加载=" + isLoaded() + " 条目数=" + map.size());
|
||||
|
||||
// 1. 精确匹配(部分 key 可直接用)
|
||||
String val = map.get(lower);
|
||||
if (val != null) {
|
||||
Bukkit.getLogger().info("[翻译] officialLookUp 命中(精确): " + lower + " → " + val);
|
||||
return val;
|
||||
}
|
||||
|
||||
// 2. block.minecraft.<name>
|
||||
val = map.get("block.minecraft." + lower);
|
||||
if (val != null) {
|
||||
Bukkit.getLogger().info("[翻译] officialLookUp 命中(block): " + lower + " → " + val);
|
||||
return val;
|
||||
}
|
||||
|
||||
// 3. item.minecraft.<name>
|
||||
val = map.get("item.minecraft." + lower);
|
||||
if (val != null) {
|
||||
Bukkit.getLogger().info("[翻译] officialLookUp 命中(item): " + lower + " → " + val);
|
||||
return val;
|
||||
}
|
||||
|
||||
Bukkit.getLogger().info("[翻译] officialLookUp 未命中: " + lower + ",将走机翻兜底");
|
||||
return null; // 未命中 -> 调用者走机翻
|
||||
}
|
||||
|
||||
/** 当前加载的条目数(调试用) */
|
||||
public static int size() {
|
||||
return map.size();
|
||||
}
|
||||
|
||||
/** 是否已完成至少一次加载(缓存或下载) */
|
||||
public static boolean isLoaded() {
|
||||
return !map.isEmpty();
|
||||
}
|
||||
|
||||
// ---------- 内部 ----------
|
||||
|
||||
/** 解析 zh_cn.json 字符串灌入内存 */
|
||||
static void loadFromJson(String json) {
|
||||
JsonObject obj = gson.fromJson(json, JsonObject.class);
|
||||
if (obj == null) return;
|
||||
map.clear();
|
||||
for (Map.Entry<String, com.google.gson.JsonElement> entry : obj.entrySet()) {
|
||||
map.put(entry.getKey(), entry.getValue().getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 Mojang API 拉取 zh_cn.json(完整链:manifest → version → asset index → object) */
|
||||
static String downloadFromMojang(String version) throws Exception {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
|
||||
// Step 1: Version manifest
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create("https://launchermeta.mojang.com/mc/game/version_manifest.json"))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.header("User-Agent", "Sdf1Login/1.0")
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() != 200) return null;
|
||||
|
||||
JsonObject manifest = gson.fromJson(resp.body(), JsonObject.class);
|
||||
|
||||
// 在 versions 数组中找目标版本
|
||||
String versionUrl = null;
|
||||
JsonArray versions = manifest.getAsJsonArray("versions");
|
||||
if (versions != null) {
|
||||
for (int i = 0; i < versions.size(); i++) {
|
||||
JsonObject v = versions.get(i).getAsJsonObject();
|
||||
if (version.equals(v.get("id").getAsString())) {
|
||||
versionUrl = v.get("url").getAsString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (versionUrl == null) return null;
|
||||
|
||||
// Step 2: Version JSON (获取 assetIndex url)
|
||||
req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(versionUrl))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.header("User-Agent", "Sdf1Login/1.0")
|
||||
.GET()
|
||||
.build();
|
||||
resp = client.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() != 200) return null;
|
||||
|
||||
JsonObject versionJson = gson.fromJson(resp.body(), JsonObject.class);
|
||||
JsonObject assetIndex = versionJson.getAsJsonObject("assetIndex");
|
||||
if (assetIndex == null) return null;
|
||||
String assetIndexUrl = assetIndex.get("url").getAsString();
|
||||
|
||||
// Step 3: Asset index (定位 zh_cn.json 的 hash)
|
||||
req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(assetIndexUrl))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.header("User-Agent", "Sdf1Login/1.0")
|
||||
.GET()
|
||||
.build();
|
||||
resp = client.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() != 200) return null;
|
||||
|
||||
JsonObject assets = gson.fromJson(resp.body(), JsonObject.class);
|
||||
JsonObject objects = assets.getAsJsonObject("objects");
|
||||
if (objects == null) return null;
|
||||
|
||||
JsonObject langEntry = objects.getAsJsonObject("minecraft/lang/zh_cn.json");
|
||||
if (langEntry == null) return null;
|
||||
String hash = langEntry.get("hash").getAsString();
|
||||
if (hash == null || hash.length() < 2) return null;
|
||||
|
||||
// Step 4: 下载 zh_cn.json
|
||||
String langUrl = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash;
|
||||
req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(langUrl))
|
||||
.timeout(Duration.ofSeconds(15))
|
||||
.header("User-Agent", "Sdf1Login/1.0")
|
||||
.GET()
|
||||
.build();
|
||||
resp = client.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() == 200) {
|
||||
return resp.body();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 从 Bukkit API 获取纯版本号,如 "1.21.4" */
|
||||
private static String getMinecraftVersion() {
|
||||
String ver = Bukkit.getBukkitVersion();
|
||||
if (ver == null) return null;
|
||||
int dash = ver.indexOf('-');
|
||||
return dash > 0 ? ver.substring(0, dash) : ver;
|
||||
}
|
||||
}
|
||||
@@ -108,8 +108,9 @@ public class PVPManager implements Listener {
|
||||
Class.forName(
|
||||
"org.sqlite.JDBC");
|
||||
File dbFile = new File(
|
||||
plugin.getDataFolder(),
|
||||
new File(plugin.getDataFolder(), "pvp"),
|
||||
"pvp.db");
|
||||
dbFile.getParentFile().mkdirs();
|
||||
pvpDb = DriverManager.getConnection(
|
||||
"jdbc:sqlite:"
|
||||
+ dbFile.getAbsolutePath());
|
||||
@@ -485,10 +486,32 @@ public class PVPManager implements Listener {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!has) {
|
||||
p.getInventory()
|
||||
.addItem(createTool());
|
||||
if (has) return;
|
||||
// ★ SN 管控:1人同种类仅限1个
|
||||
if (plugin.getSnManager() != null
|
||||
&& plugin.getSnManager().isBlocked(
|
||||
p, SnManager.TYPE_PVP)) {
|
||||
long cdMin = plugin.getSnManager().cooldownMinutesLeft(p.getName());
|
||||
if (cdMin > 0) {
|
||||
p.sendMessage("§c[PVP] 注销冷静期未结束,还剩约 " + cdMin + " 分钟,期间无法申领新设备");
|
||||
} else {
|
||||
p.sendMessage("§c[PVP] 你已有PVP圈地棒的有效登记,"
|
||||
+ "旧SN未注销前不能重新申领");
|
||||
}
|
||||
return;
|
||||
}
|
||||
ItemStack tool = createTool();
|
||||
if (plugin.getSnManager() != null) {
|
||||
String sn = plugin.getSnManager().applySn(
|
||||
p, SnManager.TYPE_PVP, "PVP圈地棒");
|
||||
if (sn == null) {
|
||||
p.sendMessage("§c[PVP] 申领被SN系统拒绝");
|
||||
return;
|
||||
}
|
||||
plugin.getSnManager().writeSn(
|
||||
tool, sn, SnManager.TYPE_PVP);
|
||||
}
|
||||
p.getInventory().addItem(tool);
|
||||
}
|
||||
|
||||
// ===== 边框粒子 =====
|
||||
@@ -713,8 +736,10 @@ public class PVPManager implements Listener {
|
||||
instanceof Player)) return;
|
||||
Player victim =
|
||||
(Player) event.getEntity();
|
||||
org.bukkit.entity.Entity kc =
|
||||
event.getDamageSource().getCausingEntity();
|
||||
Player killer =
|
||||
victim.getKiller();
|
||||
(kc instanceof Player) ? (Player) kc : null;
|
||||
if (killer == null) return;
|
||||
if (killer.equals(victim)) return;
|
||||
|
||||
@@ -1074,8 +1099,15 @@ public class PVPManager implements Listener {
|
||||
public boolean onCommand(Player p,
|
||||
String[] args) {
|
||||
if (args.length == 0) {
|
||||
return showStats(p,
|
||||
p.getName());
|
||||
showStats(p, p.getName());
|
||||
// ★ 同时展示竞技场总战绩
|
||||
showArenaTotalStats(p, p.getName());
|
||||
return true;
|
||||
}
|
||||
// ★ 数字参数 → 查询指定会话编号的竞技场战绩
|
||||
if (args.length == 1 && args[0].matches("\\d+")) {
|
||||
showArenaSessionStats(p, p.getName(), Integer.parseInt(args[0]));
|
||||
return true;
|
||||
}
|
||||
switch (args[0].toLowerCase()) {
|
||||
case "create":
|
||||
@@ -1112,9 +1144,48 @@ public class PVPManager implements Listener {
|
||||
case "tempban":
|
||||
return handleTempBan(
|
||||
p, args);
|
||||
case "join":
|
||||
case "arena":
|
||||
// ★ /pvp join see → 直接进入观赛(默认幽灵形态)
|
||||
if (args.length >= 2 && args[1].equalsIgnoreCase("see")) {
|
||||
plugin.getPVPArenaManager().handleSeeCommand(p, "enter");
|
||||
return true;
|
||||
}
|
||||
plugin.getPVPArenaManager().joinArena(p);
|
||||
return true;
|
||||
case "equip":
|
||||
case "adjust":
|
||||
if (!plugin.getPVPArenaManager().isInPVPArena(p.getName())) {
|
||||
p.sendMessage("§c你不在PVP竞技场中");
|
||||
return true;
|
||||
}
|
||||
plugin.getPVPArenaManager().openEquipmentSelection(p);
|
||||
return true;
|
||||
case "see":
|
||||
// PVP观赛:/pvp see=在裁判⇄幽灵两种观赛形态间切换(仅PVP世界,绝不切回无保护的普通玩家)
|
||||
plugin.getPVPArenaManager().handleSeeCommand(p);
|
||||
return true;
|
||||
case "leave":
|
||||
case "exit":
|
||||
// ★ /pvp leave see → 彻底退出观赛:恢复身份并重新触发选装、补发PVP装备
|
||||
if (args.length >= 2 && args[1].equalsIgnoreCase("see")) {
|
||||
plugin.getPVPArenaManager().handleSeeCommand(p, "exit");
|
||||
return true;
|
||||
}
|
||||
plugin.leavePVP(p);
|
||||
return true;
|
||||
default:
|
||||
return showStats(p,
|
||||
p.getName());
|
||||
showStats(p, p.getName());
|
||||
p.sendMessage("§7——— PVP指令 ———");
|
||||
p.sendMessage("§e/pvp join §7进入PVP竞技场");
|
||||
p.sendMessage("§e/pvp equip §7调整装备(食物/盾牌/弓弩)");
|
||||
p.sendMessage("§e/pvp see §7观赛:切换裁判⇄幽灵形态(仅PVP世界)");
|
||||
p.sendMessage("§e/pvp join see §7加入观赛:进入幽灵形态");
|
||||
p.sendMessage("§e/pvp leave see §7退出观赛:重新选装并补发PVP装备");
|
||||
p.sendMessage("§e/pvp leave §7离开PVP竞技场");
|
||||
p.sendMessage("§e/pvp stats [玩家] §7查看战绩");
|
||||
p.sendMessage("§e/pvp list §7查看PVP区域列表");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1335,9 +1406,28 @@ public class PVPManager implements Listener {
|
||||
if (region == null) {
|
||||
p.sendMessage("§6§l" + target
|
||||
+ " 的PVP战绩:");
|
||||
|
||||
// ★ PVP世界战绩(从PvPArenaManager获取)
|
||||
int pvpWorldKills = 0;
|
||||
int pvpWorldDeaths = 0;
|
||||
if (plugin.getPVPArenaManager() != null) {
|
||||
Map<String, Integer> wk = plugin.getPVPArenaManager().getPVPWorldKills();
|
||||
Map<String, Integer> wd = plugin.getPVPArenaManager().getPVPWorldDeaths();
|
||||
pvpWorldKills = wk.getOrDefault(target, 0);
|
||||
pvpWorldDeaths = wd.getOrDefault(target, 0);
|
||||
}
|
||||
if (pvpWorldKills > 0 || pvpWorldDeaths > 0) {
|
||||
double pvpKd = pvpWorldDeaths > 0
|
||||
? (double) pvpWorldKills / pvpWorldDeaths : pvpWorldKills;
|
||||
p.sendMessage("§e- §fPVP世界 §a击杀:" + pvpWorldKills
|
||||
+ " §c死亡:" + pvpWorldDeaths
|
||||
+ " §eKD:" + String.format("%.2f", pvpKd));
|
||||
}
|
||||
|
||||
// PVP区域战绩
|
||||
List<Map<String, Object>> all =
|
||||
getAllRegions();
|
||||
boolean has = false;
|
||||
boolean has = pvpWorldKills > 0 || pvpWorldDeaths > 0;
|
||||
for (Map<String, Object> row :
|
||||
all) {
|
||||
String rn = (String) row
|
||||
@@ -1355,6 +1445,21 @@ public class PVPManager implements Listener {
|
||||
has = true;
|
||||
}
|
||||
}
|
||||
// ★ 总计
|
||||
int totalKills = pvpWorldKills;
|
||||
int totalDeaths = pvpWorldDeaths;
|
||||
for (Map<String, Object> row : all) {
|
||||
String rn = (String) row.get("name");
|
||||
totalKills += getKills(target, rn);
|
||||
totalDeaths += getDeaths(target, rn);
|
||||
}
|
||||
if (totalKills > 0 || totalDeaths > 0) {
|
||||
double totalKd = totalDeaths > 0
|
||||
? (double) totalKills / totalDeaths : totalKills;
|
||||
p.sendMessage("§6§l- §f总计 §a击杀:" + totalKills
|
||||
+ " §c死亡:" + totalDeaths
|
||||
+ " §eKD:" + String.format("%.2f", totalKd));
|
||||
}
|
||||
if (!has)
|
||||
p.sendMessage(
|
||||
"§7暂无记录");
|
||||
@@ -1377,4 +1482,43 @@ public class PVPManager implements Listener {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ★ 展示PVP竞技场总战绩
|
||||
private void showArenaTotalStats(Player p, String target) {
|
||||
org.bukkit.OfflinePlayer off = Bukkit.getOfflinePlayer(target);
|
||||
String uuid = off.getUniqueId().toString();
|
||||
Map<String, Object> stats = plugin.getPVPArenaManager().getTotalArenaStats(uuid);
|
||||
int kills = (int) stats.get("kills");
|
||||
int deaths = (int) stats.get("deaths");
|
||||
if (kills == 0 && deaths == 0) return;
|
||||
double kd = deaths > 0 ? (double) kills / deaths : kills;
|
||||
p.sendMessage("§6§l[PVP竞技场] " + target);
|
||||
p.sendMessage(" §a击杀: " + kills + " §c死亡: " + deaths + " §eKD: " + String.format("%.2f", kd));
|
||||
}
|
||||
|
||||
// ★ 展示指定会话编号的竞技场战绩
|
||||
private void showArenaSessionStats(Player p, String target, int sessionNumber) {
|
||||
org.bukkit.OfflinePlayer off = Bukkit.getOfflinePlayer(target);
|
||||
String uuid = off.getUniqueId().toString();
|
||||
// 先查会话信息(含3天有效期检查)
|
||||
Map<String, Object> session = plugin.getPVPArenaManager().getArenaSessionByNumber(sessionNumber);
|
||||
if (session == null) {
|
||||
p.sendMessage("§c未找到编号 " + sessionNumber + " 的竞技场记录,或已超出3天查询范围");
|
||||
return;
|
||||
}
|
||||
long createdAt = (Long) session.get("created_at");
|
||||
long threeDaysMs = 3L * 24 * 60 * 60 * 1000;
|
||||
if (System.currentTimeMillis() - createdAt > threeDaysMs) {
|
||||
p.sendMessage("§c该记录已超出3天查询范围,不再支持查询");
|
||||
return;
|
||||
}
|
||||
int dbSessionId = (int) session.get("id");
|
||||
Map<String, Object> stats = plugin.getPVPArenaManager().getArenaSessionStats(uuid, dbSessionId);
|
||||
int kills = (int) stats.get("kills");
|
||||
int deaths = (int) stats.get("deaths");
|
||||
String worldName = (String) session.get("world_name");
|
||||
String dateStr = new java.text.SimpleDateFormat("yyyy-MM-dd").format(new java.util.Date(createdAt));
|
||||
p.sendMessage("§6§l[PVP竞技场#" + sessionNumber + "] " + target + " §7(" + dateStr + " " + worldName + ")");
|
||||
p.sendMessage(" §a击杀: " + kills + " §c死亡: " + deaths + " §eKD: " + String.format("%.2f", deaths > 0 ? (double) kills / deaths : kills));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,12 @@ public class PasswordUtils {
|
||||
try {
|
||||
MessageDigest md =
|
||||
MessageDigest.getInstance("SHA-256");
|
||||
md.update(Base64.getDecoder().decode(salt));
|
||||
// 清理非 Base64 字符,防止数据库中转义字符导致解析错误
|
||||
String cleanSalt = salt.replaceAll("[^A-Za-z0-9+/=]", "");
|
||||
if (cleanSalt.isEmpty()) {
|
||||
throw new IllegalArgumentException("Invalid salt: " + salt);
|
||||
}
|
||||
md.update(Base64.getDecoder().decode(cleanSalt));
|
||||
byte[] hash =
|
||||
md.digest(password.getBytes("UTF-8"));
|
||||
return Base64.getEncoder().encodeToString(hash);
|
||||
|
||||
@@ -12,8 +12,11 @@ public class PlayerStorage {
|
||||
Logger logger) {
|
||||
this.logger = logger;
|
||||
File dbFile = new File(
|
||||
dataFolder,
|
||||
new File(dataFolder,
|
||||
"任务面板"),
|
||||
"quest_data.db");
|
||||
dbFile.getParentFile()
|
||||
.mkdirs();
|
||||
try {
|
||||
Class.forName(
|
||||
"org.sqlite.JDBC");
|
||||
|
||||
@@ -259,20 +259,21 @@ public class PointsManager {
|
||||
switch (slot) {
|
||||
case 10:
|
||||
if (points >= 100) {
|
||||
plugin.getDb().deductPoints(
|
||||
p.getName(), 100);
|
||||
if (plugin.getEconomy() != null) {
|
||||
plugin.getEconomy()
|
||||
.depositPlayer(p, 1000);
|
||||
BondManager bm = plugin.getBonds();
|
||||
if (bm != null) {
|
||||
int before = bm.getBonds(p.getName());
|
||||
bm.addBonds(p.getName(), 1000, "points_exchange", "", "积分商店", "积分兑换债券");
|
||||
int after = bm.getBonds(p.getName());
|
||||
plugin.getDb().deductPoints(p.getName(), 100);
|
||||
p.sendMessage(plugin.getConfig2().msg("points_purchase_success")
|
||||
.replace("{count}", "100"));
|
||||
p.sendMessage("§a[积分] §f债券: §e" + before + " → §a" + after);
|
||||
} else {
|
||||
p.sendMessage("§c债券系统不可用");
|
||||
}
|
||||
p.sendMessage(plugin.getConfig2()
|
||||
.msg("points_purchase_success")
|
||||
.replace("{count}", "100"));
|
||||
} else {
|
||||
p.sendMessage(plugin.getConfig2()
|
||||
.msg("points_insufficient")
|
||||
.replace("{points}",
|
||||
String.valueOf(points)));
|
||||
p.sendMessage(plugin.getConfig2().msg("points_insufficient")
|
||||
.replace("{points}", String.valueOf(points)));
|
||||
}
|
||||
break;
|
||||
case 12:
|
||||
|
||||
@@ -7,132 +7,201 @@ import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
public class QuestStorage {
|
||||
|
||||
private Connection conn;
|
||||
private volatile Connection conn;
|
||||
private final String dbPath;
|
||||
private final Plugin plugin;
|
||||
private final Object connLock = new Object();
|
||||
|
||||
public QuestStorage(File dataFolder) {
|
||||
public QuestStorage(File dataFolder, Plugin plugin) {
|
||||
this.plugin = plugin;
|
||||
String path = "";
|
||||
try {
|
||||
Class.forName("org.sqlite.JDBC");
|
||||
File dbFile = new File(
|
||||
dataFolder,
|
||||
new File(dataFolder, "任务面板"),
|
||||
"quest_data.db");
|
||||
conn = DriverManager.getConnection(
|
||||
"jdbc:sqlite:"
|
||||
+ dbFile.getPath());
|
||||
initTable();
|
||||
dbFile.getParentFile().mkdirs();
|
||||
path = dbFile.getPath();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
dbPath = path;
|
||||
|
||||
conn = openConnection();
|
||||
if (conn != null) {
|
||||
initTable();
|
||||
}
|
||||
}
|
||||
|
||||
private void initTable()
|
||||
throws SQLException {
|
||||
Statement stmt =
|
||||
conn.createStatement();
|
||||
stmt.executeUpdate(
|
||||
"CREATE TABLE IF NOT EXISTS "
|
||||
+ "quest_conditions ("
|
||||
+ "player TEXT NOT NULL, "
|
||||
+ "condition_key TEXT NOT NULL, "
|
||||
+ "value TEXT DEFAULT '1', "
|
||||
+ "timestamp INTEGER, "
|
||||
+ "PRIMARY KEY "
|
||||
+ "(player, condition_key))");
|
||||
stmt.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记条件已完成(value = "1")
|
||||
*/
|
||||
public void setCondition(
|
||||
String player,
|
||||
String condition) {
|
||||
setConditionRaw(
|
||||
player, condition, "1");
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入任意值(计数器等)
|
||||
*/
|
||||
public void setConditionRaw(
|
||||
String player,
|
||||
String condition,
|
||||
String value) {
|
||||
private Connection openConnection() {
|
||||
String url = "jdbc:sqlite:" + dbPath
|
||||
+ "?journal_mode=WAL"
|
||||
+ "&synchronous=NORMAL"
|
||||
+ "&busy_timeout=5000";
|
||||
try {
|
||||
PreparedStatement ps =
|
||||
conn.prepareStatement(
|
||||
"INSERT OR REPLACE "
|
||||
+ "INTO "
|
||||
+ "quest_conditions "
|
||||
+ "(player, "
|
||||
+ "condition_key, "
|
||||
+ "value, timestamp) "
|
||||
+ "VALUES "
|
||||
+ "(?, ?, ?, ?)");
|
||||
ps.setString(1, player);
|
||||
ps.setString(2, condition);
|
||||
ps.setString(3, value);
|
||||
ps.setLong(4,
|
||||
System.currentTimeMillis());
|
||||
ps.executeUpdate();
|
||||
ps.close();
|
||||
return DriverManager.getConnection(url);
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量级连接检查 — 仅检查本地状态,不执行SQL
|
||||
* 避免 isValid() 在主线程上阻塞
|
||||
*/
|
||||
private void ensureConnection() {
|
||||
try {
|
||||
if (conn != null && !conn.isClosed()) {
|
||||
return;
|
||||
}
|
||||
} catch (SQLException ignored) {}
|
||||
synchronized (connLock) {
|
||||
try { if (conn != null) conn.close(); }
|
||||
catch (Exception ignored) {}
|
||||
conn = openConnection();
|
||||
}
|
||||
}
|
||||
|
||||
private void initTable() {
|
||||
try {
|
||||
conn.createStatement().executeUpdate(
|
||||
"CREATE TABLE IF NOT EXISTS quest_conditions ("
|
||||
+ "player TEXT NOT NULL, "
|
||||
+ "condition_key TEXT NOT NULL, "
|
||||
+ "value TEXT DEFAULT '1', "
|
||||
+ "timestamp INTEGER, "
|
||||
+ "PRIMARY KEY (player, condition_key))");
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public void setCondition(String player, String condition) {
|
||||
setConditionRaw(player, condition, "1");
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询条件值
|
||||
* 异步写入 — 不阻塞主线程
|
||||
* 使用 Bukkit 异步调度,在独立线程中执行 SQLite 写操作
|
||||
*/
|
||||
public Object getCondition(
|
||||
String player,
|
||||
String condition) {
|
||||
try {
|
||||
PreparedStatement ps =
|
||||
conn.prepareStatement(
|
||||
"SELECT value FROM "
|
||||
+ "quest_conditions "
|
||||
+ "WHERE player = ? "
|
||||
+ "AND "
|
||||
+ "condition_key = ?");
|
||||
ps.setString(1, player);
|
||||
ps.setString(2, condition);
|
||||
ResultSet rs =
|
||||
ps.executeQuery();
|
||||
if (rs.next()) {
|
||||
String val =
|
||||
rs.getString("value");
|
||||
rs.close();
|
||||
ps.close();
|
||||
// 尝试解析为数字
|
||||
public void setConditionRaw(String player,
|
||||
String condition, String value) {
|
||||
final String sql =
|
||||
"INSERT OR REPLACE INTO quest_conditions "
|
||||
+ "(player, condition_key, value, timestamp) "
|
||||
+ "VALUES (?, ?, ?, ?)";
|
||||
final long ts = System.currentTimeMillis();
|
||||
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
writeSync(sql, player, condition, value, ts);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在异步线程中执行写入,带重试
|
||||
*/
|
||||
private void writeSync(String sql, String player,
|
||||
String condKey, String value, long timestamp) {
|
||||
synchronized (connLock) {
|
||||
for (int attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
if (val.contains(".")) {
|
||||
return Double.parseDouble(
|
||||
val);
|
||||
ensureConnection();
|
||||
if (conn == null) return;
|
||||
PreparedStatement ps = conn.prepareStatement(sql);
|
||||
ps.setString(1, player);
|
||||
ps.setString(2, condKey);
|
||||
ps.setString(3, value);
|
||||
ps.setLong(4, timestamp);
|
||||
ps.executeUpdate();
|
||||
ps.close();
|
||||
return;
|
||||
} catch (SQLException e) {
|
||||
String msg = e.getMessage();
|
||||
if (msg != null && (msg.contains("SQLITE_BUSY")
|
||||
|| msg.contains("database is locked"))) {
|
||||
try {
|
||||
Thread.sleep(200 + attempt * 200);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
e.printStackTrace();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步读取(主线程)
|
||||
* WAL 模式下读操作不阻塞写,可安全在主线程执行
|
||||
*/
|
||||
public Object getCondition(String player,
|
||||
String condition) {
|
||||
synchronized (connLock) {
|
||||
for (int attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
ensureConnection();
|
||||
if (conn == null) return null;
|
||||
PreparedStatement ps = conn.prepareStatement(
|
||||
"SELECT value FROM quest_conditions "
|
||||
+ "WHERE player = ? "
|
||||
+ "AND condition_key = ?");
|
||||
ps.setString(1, player);
|
||||
ps.setString(2, condition);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
if (rs.next()) {
|
||||
String val = rs.getString("value");
|
||||
rs.close();
|
||||
ps.close();
|
||||
try {
|
||||
if (val.contains("."))
|
||||
return Double.parseDouble(val);
|
||||
return Long.parseLong(val);
|
||||
} catch (NumberFormatException e) {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
rs.close();
|
||||
ps.close();
|
||||
return null;
|
||||
} catch (SQLException e) {
|
||||
String msg = e.getMessage();
|
||||
if (msg != null && (msg.contains("SQLITE_BUSY")
|
||||
|| msg.contains("database is locked"))) {
|
||||
try {
|
||||
Thread.sleep(100 + attempt * 100);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
}
|
||||
return Long.parseLong(val);
|
||||
} catch (NumberFormatException e) {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
rs.close();
|
||||
ps.close();
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void close() {
|
||||
try {
|
||||
if (conn != null
|
||||
&& !conn.isClosed()) {
|
||||
conn.close();
|
||||
synchronized (connLock) {
|
||||
try {
|
||||
if (conn != null && !conn.isClosed()) {
|
||||
conn.close();
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,17 @@ import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Registry;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.generator.structure.Structure;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
// 如果 Bonds 在 Sdf1_login 包下:
|
||||
import Sdf1_login.BondManager;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -33,6 +40,8 @@ public class QuestTracker {
|
||||
checkInRecords = new ArrayList<>();
|
||||
private final Map<String, String>
|
||||
lastCheckInPos = new HashMap<>();
|
||||
private static boolean loggedStructureRegistry
|
||||
= false;
|
||||
|
||||
public void recordCheckIn(
|
||||
String player,
|
||||
@@ -78,11 +87,19 @@ public class QuestTracker {
|
||||
public QuestTracker(Plugin plugin) {
|
||||
this.plugin = plugin;
|
||||
this.storage = new QuestStorage(
|
||||
plugin.getDataFolder());
|
||||
plugin.getDataFolder(), plugin);
|
||||
|
||||
loadAllQuests();
|
||||
}
|
||||
|
||||
// ========== 重载任务配置 ==========
|
||||
public void reload() {
|
||||
questCache.clear();
|
||||
loadAllQuests();
|
||||
plugin.getLogger().info(
|
||||
"[QuestTracker] 任务配置已重载");
|
||||
}
|
||||
|
||||
public void debugPlayer(String player) {
|
||||
plugin.getLogger().info(
|
||||
"[QuestTracker] === "
|
||||
@@ -219,14 +236,27 @@ public class QuestTracker {
|
||||
"下界", "末地"
|
||||
};
|
||||
|
||||
// ===== 结构映射 =====
|
||||
// ===== 结构映射(PaperMC 1.21.4 实际结构 key)=====
|
||||
private static final Map<String, String>
|
||||
STRUCTURE_CATEGORY =
|
||||
new HashMap<>();
|
||||
static {
|
||||
// 主世界结构
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:village", "村庄");
|
||||
"minecraft:village_plains",
|
||||
"村庄");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:village_desert",
|
||||
"村庄");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:village_savanna",
|
||||
"村庄");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:village_snowy",
|
||||
"村庄");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:village_taiga",
|
||||
"村庄");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:desert_pyramid",
|
||||
"沙漠");
|
||||
@@ -241,24 +271,39 @@ public class QuestTracker {
|
||||
"minecraft:mansion", "森林");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:pillager_outpost",
|
||||
"平原");
|
||||
"掠夺者前哨站");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:mineshaft", "地下");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:stronghold", "地下");
|
||||
"minecraft:mineshaft_mesa",
|
||||
"地下");
|
||||
// 要塞是独立的探索结构(通往末地的传送门所在地)
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:stronghold",
|
||||
"末地要塞");
|
||||
// 远古城市是结构,应分类为"远古城市"而非"地下"
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:ancient_city",
|
||||
"地下");
|
||||
"远古城市");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:trail_ruins",
|
||||
"平原");
|
||||
"远古遗迹");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:trial_chambers",
|
||||
"地下");
|
||||
"试炼密室");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:ocean_ruin", "海洋");
|
||||
"minecraft:monument", "海洋");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:ocean_ruin_cold",
|
||||
"海洋");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:ocean_ruin_warm",
|
||||
"海洋");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:shipwreck", "海洋");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:shipwreck_beached",
|
||||
"海洋");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:buried_treasure",
|
||||
"海洋");
|
||||
@@ -266,14 +311,23 @@ public class QuestTracker {
|
||||
"minecraft:ruined_portal",
|
||||
"平原");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:fossil", "平原");
|
||||
"minecraft:ruined_portal_desert",
|
||||
"沙漠");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:ancient_city",
|
||||
"地下");
|
||||
"minecraft:ruined_portal_jungle",
|
||||
"森林");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:ruined_portal_mountain",
|
||||
"山地");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:ruined_portal_ocean",
|
||||
"海洋");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:ruined_portal_swamp",
|
||||
"沼泽");
|
||||
// 下界结构
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:nether_fortress",
|
||||
"下界");
|
||||
"minecraft:fortress", "下界");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:bastion_remnant",
|
||||
"下界");
|
||||
@@ -281,8 +335,11 @@ public class QuestTracker {
|
||||
"minecraft:nether_fossil",
|
||||
"下界");
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:basalt_pillars",
|
||||
"minecraft:ruined_portal_nether",
|
||||
"下界");
|
||||
// 末地结构
|
||||
STRUCTURE_CATEGORY.put(
|
||||
"minecraft:end_city", "末地");
|
||||
}
|
||||
|
||||
// 所有群系分类的条件关键词映射
|
||||
@@ -310,6 +367,40 @@ public class QuestTracker {
|
||||
CATEGORY_KEYWORDS.put("沙滩", "石岸");
|
||||
CATEGORY_KEYWORDS.put("海岸", "石岸");
|
||||
|
||||
// ===== 结构名称关键词映射 =====
|
||||
// 主世界结构
|
||||
CATEGORY_KEYWORDS.put("村庄", "村庄");
|
||||
CATEGORY_KEYWORDS.put("沙漠神殿", "沙漠");
|
||||
CATEGORY_KEYWORDS.put("丛林神庙", "森林");
|
||||
CATEGORY_KEYWORDS.put("沼泽小屋", "沼泽");
|
||||
CATEGORY_KEYWORDS.put("雪屋", "雪原");
|
||||
CATEGORY_KEYWORDS.put("林地府邸", "森林");
|
||||
CATEGORY_KEYWORDS.put("掠夺者前哨站", "平原");
|
||||
CATEGORY_KEYWORDS.put("废弃矿井", "地下");
|
||||
CATEGORY_KEYWORDS.put("要塞", "末地要塞");
|
||||
CATEGORY_KEYWORDS.put("末地要塞", "末地要塞");
|
||||
CATEGORY_KEYWORDS.put("远古城市", "远古城市");
|
||||
CATEGORY_KEYWORDS.put("古迹废墟", "地下");
|
||||
CATEGORY_KEYWORDS.put("试炼密室", "地下");
|
||||
CATEGORY_KEYWORDS.put("海底废墟", "海洋");
|
||||
CATEGORY_KEYWORDS.put("沉船", "海洋");
|
||||
CATEGORY_KEYWORDS.put("埋藏的宝箱", "海洋");
|
||||
CATEGORY_KEYWORDS.put("废弃传送门", "主世界");
|
||||
CATEGORY_KEYWORDS.put("化石", "主世界");
|
||||
|
||||
// 下界结构
|
||||
CATEGORY_KEYWORDS.put("下界要塞", "下界");
|
||||
CATEGORY_KEYWORDS.put("猪灵堡垒", "下界");
|
||||
CATEGORY_KEYWORDS.put("玄武岩结构", "下界");
|
||||
|
||||
// 末地结构
|
||||
CATEGORY_KEYWORDS.put("末地城", "末地");
|
||||
CATEGORY_KEYWORDS.put("末地船", "末地");
|
||||
|
||||
// 下界相关别名
|
||||
CATEGORY_KEYWORDS.put("地狱要塞", "下界");
|
||||
CATEGORY_KEYWORDS.put("下界堡垒", "下界");
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -365,6 +456,223 @@ public class QuestTracker {
|
||||
return "主世界";
|
||||
}
|
||||
|
||||
// ========== 结构检测 ==========
|
||||
|
||||
/**
|
||||
* 检测指定位置是否存在自然生成的结构
|
||||
* 返回结构分类(如"村庄"、"沙漠"等),未找到返回null
|
||||
* 使用 PaperMC World.hasStructureAt() + Registry.STRUCTURE API
|
||||
*/
|
||||
private String detectStructureAt(
|
||||
String worldName, double x,
|
||||
double y, double z) {
|
||||
try {
|
||||
World w = Bukkit.getWorld(worldName);
|
||||
if (w == null) {
|
||||
plugin.getLogger().warning(
|
||||
"[QuestTracker] 结构检测: 世界 '"
|
||||
+ worldName + "' 不存在");
|
||||
return null;
|
||||
}
|
||||
|
||||
int bx = (int) Math.floor(x);
|
||||
int by = (int) Math.floor(y);
|
||||
int bz = (int) Math.floor(z);
|
||||
|
||||
// 首次调用时打印所有注册表结构键
|
||||
if (!loggedStructureRegistry) {
|
||||
plugin.getLogger().info(
|
||||
"[QuestTracker] === 结构注册表诊断 ===");
|
||||
int count = 0;
|
||||
for (Structure s : Registry.STRUCTURE) {
|
||||
NamespacedKey key =
|
||||
Registry.STRUCTURE.getKey(s);
|
||||
plugin.getLogger().info(
|
||||
"[QuestTracker] 注册表结构 #"
|
||||
+ count + ": " + key);
|
||||
count++;
|
||||
}
|
||||
plugin.getLogger().info(
|
||||
"[QuestTracker] 注册表共 "
|
||||
+ count + " 个结构");
|
||||
plugin.getLogger().info(
|
||||
"[QuestTracker] === 诊断结束 ===");
|
||||
loggedStructureRegistry = true;
|
||||
}
|
||||
|
||||
// 第1步: 精确位置检查
|
||||
io.papermc.paper.math.Position exactPos =
|
||||
io.papermc.paper.math.Position.block(
|
||||
bx, by, bz);
|
||||
for (Structure structure : Registry.STRUCTURE) {
|
||||
boolean hasAt =
|
||||
w.hasStructureAt(exactPos, structure);
|
||||
if (hasAt) {
|
||||
NamespacedKey key =
|
||||
Registry.STRUCTURE
|
||||
.getKey(structure);
|
||||
String keyStr = (key != null)
|
||||
? key.toString() : "unknown";
|
||||
String cat =
|
||||
STRUCTURE_CATEGORY.get(keyStr);
|
||||
plugin.getLogger().info(
|
||||
"[QuestTracker] 结构匹配(精确): "
|
||||
+ keyStr + " -> category="
|
||||
+ (cat != null
|
||||
? cat : "未映射"));
|
||||
if (cat != null) return cat;
|
||||
}
|
||||
}
|
||||
|
||||
// 第2步: Y轴偏移检查 (-5 到 +10)
|
||||
for (int dy = -5; dy <= 10; dy++) {
|
||||
if (dy == 0) continue;
|
||||
int testY = by + dy;
|
||||
io.papermc.paper.math.Position testPos =
|
||||
io.papermc.paper.math.Position.block(
|
||||
bx, testY, bz);
|
||||
for (Structure structure
|
||||
: Registry.STRUCTURE) {
|
||||
boolean hasAt = w.hasStructureAt(
|
||||
testPos, structure);
|
||||
if (hasAt) {
|
||||
NamespacedKey key =
|
||||
Registry.STRUCTURE
|
||||
.getKey(structure);
|
||||
String keyStr = (key != null)
|
||||
? key.toString() : "unknown";
|
||||
String cat =
|
||||
STRUCTURE_CATEGORY
|
||||
.get(keyStr);
|
||||
plugin.getLogger().info(
|
||||
"[QuestTracker] 结构匹配(Y偏移"
|
||||
+ dy + "): "
|
||||
+ keyStr
|
||||
+ " -> category="
|
||||
+ (cat != null
|
||||
? cat : "未映射"));
|
||||
if (cat != null) return cat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第3步: 水平偏移检查 (±1格)
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
for (int dz = -1; dz <= 1; dz++) {
|
||||
if (dx == 0 && dz == 0) continue;
|
||||
io.papermc.paper.math.Position offsetPos =
|
||||
io.papermc.paper.math.Position
|
||||
.block(bx + dx, by,
|
||||
bz + dz);
|
||||
for (Structure structure
|
||||
: Registry.STRUCTURE) {
|
||||
boolean hasAt = w.hasStructureAt(
|
||||
offsetPos, structure);
|
||||
if (hasAt) {
|
||||
NamespacedKey key =
|
||||
Registry.STRUCTURE
|
||||
.getKey(
|
||||
structure);
|
||||
String keyStr = (key != null)
|
||||
? key.toString()
|
||||
: "unknown";
|
||||
String cat =
|
||||
STRUCTURE_CATEGORY
|
||||
.get(keyStr);
|
||||
plugin.getLogger().info(
|
||||
"[QuestTracker] 结构匹配(偏移"
|
||||
+ dx + ","
|
||||
+ dz + "): "
|
||||
+ keyStr
|
||||
+ " -> category="
|
||||
+ (cat != null
|
||||
? cat
|
||||
: "未映射"));
|
||||
if (cat != null) return cat;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plugin.getLogger().info(
|
||||
"[QuestTracker] 所有位置检查完毕, "
|
||||
+ "未匹配任何结构 (pos="
|
||||
+ bx + "," + by + "," + bz
|
||||
+ ")");
|
||||
} catch (Exception ex) {
|
||||
plugin.getLogger().warning(
|
||||
"[QuestTracker] 结构检测异常: "
|
||||
+ ex.getClass().getSimpleName()
|
||||
+ ": " + ex.getMessage());
|
||||
ex.printStackTrace();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将结构中文名映射到对应的大类
|
||||
* 用于匹配任务条件中的结构名
|
||||
*/
|
||||
private String mapStructureCategory(
|
||||
String structureName) {
|
||||
if (structureName == null) return "";
|
||||
|
||||
// 主世界结构
|
||||
if (structureName.contains("村庄"))
|
||||
return "村庄";
|
||||
if (structureName.contains("沙漠神殿"))
|
||||
return "沙漠";
|
||||
if (structureName.contains("丛林神庙") || structureName.contains("丛林神殿"))
|
||||
return "森林";
|
||||
if (structureName.contains("沼泽小屋"))
|
||||
return "沼泽";
|
||||
if (structureName.contains("雪屋") || structureName.contains("冰屋"))
|
||||
return "雪原";
|
||||
if (structureName.contains("林地府邸"))
|
||||
return "森林";
|
||||
if (structureName.contains("掠夺者前哨站"))
|
||||
return "掠夺者前哨站";
|
||||
if (structureName.contains("废弃矿井") || structureName.contains("恶地废弃矿井"))
|
||||
return "地下";
|
||||
// 要塞是独立的探索结构
|
||||
if (structureName.contains("要塞"))
|
||||
return "末地要塞";
|
||||
// 远古城市是结构,应分类为"远古城市"
|
||||
if (structureName.contains("远古城市"))
|
||||
return "远古城市";
|
||||
if (structureName.contains("远古遗迹") || structureName.contains("古迹废墟"))
|
||||
return "远古遗迹";
|
||||
if (structureName.contains("试炼密室"))
|
||||
return "试炼密室";
|
||||
if (structureName.contains("海底神殿") || structureName.contains("海洋神殿"))
|
||||
return "海洋";
|
||||
if (structureName.contains("海底废墟") || structureName.contains("冻洋废墟") || structureName.contains("暖洋废墟"))
|
||||
return "海洋";
|
||||
if (structureName.contains("沉船"))
|
||||
return "海洋";
|
||||
if (structureName.contains("埋藏的宝箱") || structureName.contains("埋藏的宝藏"))
|
||||
return "海洋";
|
||||
if (structureName.contains("废弃传送门"))
|
||||
return "平原";
|
||||
if (structureName.contains("化石"))
|
||||
return "主世界";
|
||||
|
||||
// 下界结构
|
||||
if (structureName.contains("下界要塞"))
|
||||
return "下界";
|
||||
if (structureName.contains("猪灵堡垒") || structureName.contains("堡垒遗迹"))
|
||||
return "下界";
|
||||
if (structureName.contains("玄武岩结构") || structureName.contains("下界化石"))
|
||||
return "下界";
|
||||
|
||||
// 末地结构
|
||||
if (structureName.contains("末地城"))
|
||||
return "末地";
|
||||
if (structureName.contains("末地船"))
|
||||
return "末地";
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
// ========== 解析为区块 ==========
|
||||
@@ -892,7 +1200,7 @@ public class QuestTracker {
|
||||
|
||||
/**
|
||||
* 发放阶段奖励
|
||||
* 含经济内容的走 Vault API
|
||||
* 含经济内容的走 Vault API或债券
|
||||
*/
|
||||
public void claimRewards(
|
||||
Player player,
|
||||
@@ -900,13 +1208,11 @@ public class QuestTracker {
|
||||
if (qf == null
|
||||
|| qf.rewards == null
|
||||
|| qf.rewards.isEmpty()) {
|
||||
player.sendMessage(
|
||||
"§c无可领取的奖励");
|
||||
player.sendMessage("§c无可领取的奖励");
|
||||
return;
|
||||
}
|
||||
|
||||
net.milkbowl.vault.economy.Economy econ =
|
||||
((Main) plugin).getEconomy();
|
||||
Main main = (Main) plugin;
|
||||
|
||||
int sentCount = 0;
|
||||
|
||||
@@ -916,52 +1222,34 @@ public class QuestTracker {
|
||||
|
||||
// 检查可选奖励标记
|
||||
if (hasOptionalMarker(rt)) {
|
||||
if (!areAllConditionsMet(player.getName(), qf))
|
||||
if (!areAllConditionsMet(player.getName(), qf)) {
|
||||
player.sendMessage(
|
||||
"§7[可选奖励] "
|
||||
+ "需完成所有可选条件: "
|
||||
+ rt);
|
||||
"§7[可选奖励] 需完成所有可选条件: " + rt);
|
||||
continue;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 按 + 分割子项
|
||||
String[] parts =
|
||||
rt.split("[+,]");
|
||||
String[] parts = rt.split("[+,]");
|
||||
for (String part : parts) {
|
||||
part = part.trim();
|
||||
if (part.isEmpty()) continue;
|
||||
|
||||
// 尝试发经济
|
||||
double amt =
|
||||
extractEconomyAmount(part);
|
||||
if (amt > 0 && econ != null) {
|
||||
double before =
|
||||
econ.getBalance(player);
|
||||
econ.depositPlayer(
|
||||
player, amt);
|
||||
player.sendMessage(
|
||||
"§a§l[任务奖励] §f"
|
||||
+ part
|
||||
+ " §7(余额: §e"
|
||||
+ String.format(
|
||||
"%.2f", before)
|
||||
+ " §7→ §a"
|
||||
+ String.format(
|
||||
"%.2f",
|
||||
econ.getBalance(
|
||||
player))
|
||||
+ "§7)");
|
||||
// 通知提成系统
|
||||
if (plugin instanceof Main) {
|
||||
((Main) plugin)
|
||||
.getCommission()
|
||||
.payCommission(
|
||||
player
|
||||
.getName(),
|
||||
amt);
|
||||
// 尝试识别经济金额
|
||||
double amt = extractEconomyAmount(part);
|
||||
if (amt > 0) {
|
||||
// Economy已移除,全部转为债券发放
|
||||
// —— 债券 ——
|
||||
int bondAmt = (int) Math.round(amt);
|
||||
BondManager bonds = main.getBonds();
|
||||
if (bonds != null) {
|
||||
int before = bonds.getBonds(player.getName());
|
||||
bonds.addBonds(player.getName(), bondAmt, "quest_reward", qf.displayName, "任务系统", "任务奖励:" + qf.displayName);
|
||||
int after = bonds.getBonds(player.getName());
|
||||
player.sendMessage("§a§l[任务奖励] §f" + part + " §7(债券: §e" + before + " §7→ §a" + after + "§7)");
|
||||
} else {
|
||||
player.sendMessage("§c债券系统不可用");
|
||||
}
|
||||
|
||||
sentCount++;
|
||||
continue;
|
||||
}
|
||||
@@ -971,32 +1259,27 @@ public class QuestTracker {
|
||||
parseRewardItems(part);
|
||||
if (!items.isEmpty()) {
|
||||
for (ItemStack is : items) {
|
||||
player.getInventory()
|
||||
.addItem(is);
|
||||
player.getInventory().addItem(is);
|
||||
}
|
||||
player.sendMessage(
|
||||
"§a§l[任务奖励] §f"
|
||||
+ part);
|
||||
"§a§l[任务奖励] §f" + part);
|
||||
sentCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 非经济非物品 → 仅消息
|
||||
player.sendMessage(
|
||||
"§a§l[任务奖励] §f"
|
||||
+ part);
|
||||
"§a§l[任务奖励] §f" + part);
|
||||
sentCount++;
|
||||
}
|
||||
}
|
||||
|
||||
plugin.getLogger().info(
|
||||
"[QuestTracker] "
|
||||
+ player.getName()
|
||||
+ " 领取了 \""
|
||||
+ qf.displayName
|
||||
+ "\" 共 " + sentCount
|
||||
+ " 项奖励");
|
||||
"[QuestTracker] " + player.getName()
|
||||
+ " 预取了 \"" + qf.displayName
|
||||
+ "\" 共 " + sentCount + "项奖励");
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查奖励文本是否含可选标记
|
||||
*/
|
||||
@@ -1043,7 +1326,9 @@ public class QuestTracker {
|
||||
|| t.contains("$")
|
||||
|| t.contains("经济")
|
||||
|| t.contains("余额")
|
||||
|| t.contains("发钱");
|
||||
|| t.contains("发钱")
|
||||
|| t.contains("债券"); // ★ 新增
|
||||
|
||||
|
||||
if (!hasEcon) return 0;
|
||||
|
||||
@@ -1644,6 +1929,24 @@ public class QuestTracker {
|
||||
+ total + "/1";
|
||||
}
|
||||
|
||||
// 打卡结构条件:打卡X个结构名 → 显示进度
|
||||
if (key.contains("打卡")
|
||||
&& !key.contains("群系")
|
||||
&& !key.contains("风景")) {
|
||||
String target = extractBiome(key);
|
||||
if (!target.isEmpty()) {
|
||||
int needed = parseNumberFromText(key);
|
||||
if (needed <= 0) needed = 1;
|
||||
int cur = getCounter(player,
|
||||
"biomecat_" + target);
|
||||
boolean done = cur >= needed;
|
||||
return (done ? "§a" : "§7")
|
||||
+ "打卡 " + target + ": §e"
|
||||
+ Math.min(cur, needed)
|
||||
+ "/" + needed;
|
||||
}
|
||||
}
|
||||
|
||||
if (key.contains("完成")
|
||||
&& key.contains("阶段")) {
|
||||
boolean done = checkStageDependency(
|
||||
@@ -1715,11 +2018,19 @@ public class QuestTracker {
|
||||
Object val =
|
||||
getDb().getField(
|
||||
player,
|
||||
"checkin_streak");
|
||||
"login_streak");
|
||||
if (val == null || (val instanceof Number && ((Number) val).intValue() == 0)) {
|
||||
// 兼容旧数据:如果没有login_streak则读取checkin_streak
|
||||
val = getDb().getField(
|
||||
player,
|
||||
"checkin_streak");
|
||||
}
|
||||
int streak = val instanceof Number
|
||||
? ((Number) val).intValue()
|
||||
: 0;
|
||||
return "§7连续登录: §e"
|
||||
boolean done = streak >= needed;
|
||||
return (done ? "§a" : "§7")
|
||||
+ "连续登录: §e"
|
||||
+ streak + "/" + needed + "天";
|
||||
}
|
||||
|
||||
@@ -1863,7 +2174,12 @@ public class QuestTracker {
|
||||
if (needed <= 0) needed = 1;
|
||||
int streak = getIntField(
|
||||
player,
|
||||
"checkin_streak");
|
||||
"login_streak");
|
||||
if (streak == 0) {
|
||||
streak = getIntField(
|
||||
player,
|
||||
"checkin_streak");
|
||||
}
|
||||
if (streak == 0) {
|
||||
streak = getIntField(
|
||||
player, "streak");
|
||||
@@ -1873,7 +2189,9 @@ public class QuestTracker {
|
||||
player,
|
||||
"total_checkin_days");
|
||||
}
|
||||
return "§7近7天登录: §e"
|
||||
boolean done = streak >= needed;
|
||||
return (done ? "§a" : "§7")
|
||||
+ "近7天登录: §e"
|
||||
+ streak + "/"
|
||||
+ needed + "天";
|
||||
}
|
||||
@@ -2155,23 +2473,58 @@ public class QuestTracker {
|
||||
String category =
|
||||
mapBiomeCategory(
|
||||
rawBiomeKey, worldName);
|
||||
// 结构检测(覆盖纯生物群系分类)
|
||||
// 地下检测:Y低于表面则归类为地下
|
||||
// 覆盖废弃矿井/试炼密室/要塞/远古城市等
|
||||
|
||||
// ===== 结构检测优先于Y轴"地下"判断 =====
|
||||
// 要塞等结构可生成在任何主世界群系下,不应固定为"地下"
|
||||
// 使用 PaperMC API 检测玩家所在位置的结构
|
||||
String structureCat = null;
|
||||
try {
|
||||
org.bukkit.World uw =
|
||||
Bukkit.getWorld(worldName);
|
||||
if (uw != null) {
|
||||
int surfaceY = uw
|
||||
.getHighestBlockYAt(
|
||||
(int) Math.floor(x),
|
||||
(int) Math.floor(z));
|
||||
if (y < surfaceY - 8) {
|
||||
category = "地下";
|
||||
}
|
||||
}
|
||||
structureCat = detectStructureAt(
|
||||
worldName, x, y, z);
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
if (structureCat != null
|
||||
&& !structureCat.isEmpty()
|
||||
&& !"地下".equals(structureCat)) {
|
||||
// 结构检测成功且非"地下"类,结构分类优先
|
||||
category = structureCat;
|
||||
plugin.getLogger().info(
|
||||
"[QuestTracker] 结构覆盖: "
|
||||
+ category
|
||||
+ " (结构: "
|
||||
+ structureCat + ")");
|
||||
} else {
|
||||
// 未检测到有效结构,使用Y轴检测判定地下
|
||||
// 地下检测:Y低于表面则归类为地下
|
||||
try {
|
||||
org.bukkit.World uw =
|
||||
Bukkit.getWorld(worldName);
|
||||
if (uw != null) {
|
||||
int surfaceY = uw
|
||||
.getHighestBlockYAt(
|
||||
(int) Math.floor(x),
|
||||
(int) Math.floor(z));
|
||||
if (y < surfaceY - 8) {
|
||||
category = "地下";
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
if (structureCat != null
|
||||
&& !structureCat.isEmpty()) {
|
||||
plugin.getLogger().info(
|
||||
"[QuestTracker] 结构"
|
||||
+ structureCat
|
||||
+ " 使用Y轴判定");
|
||||
} else {
|
||||
plugin.getLogger().info(
|
||||
"[QuestTracker] 未检测到结构: world="
|
||||
+ worldName + ", pos="
|
||||
+ String.format(
|
||||
"%.0f,%.0f,%.0f",
|
||||
x, y, z));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 记录总打卡
|
||||
int allCur = getCounter(
|
||||
@@ -2179,10 +2532,11 @@ public class QuestTracker {
|
||||
setCounter(player, "biome_ALL",
|
||||
allCur + 1);
|
||||
|
||||
// 记录分类打卡
|
||||
// 记录分类打卡(群系,排除结构避免重复计数)
|
||||
if (!category.isEmpty()
|
||||
&& !"主世界"
|
||||
.equals(category)) {
|
||||
&& !"主世界".equals(category)
|
||||
&& (structureCat == null
|
||||
|| !category.equals(structureCat))) {
|
||||
String catKey =
|
||||
"biomecat_" + category;
|
||||
int catCur = getCounter(
|
||||
@@ -2191,6 +2545,17 @@ public class QuestTracker {
|
||||
catCur + 1);
|
||||
}
|
||||
|
||||
// 记录结构打卡(结构单独计数)
|
||||
if (structureCat != null
|
||||
&& !structureCat.isEmpty()) {
|
||||
String structKey =
|
||||
"biomecat_" + structureCat;
|
||||
int structCur = getCounter(
|
||||
player, structKey);
|
||||
setCounter(player, structKey,
|
||||
structCur + 1);
|
||||
}
|
||||
|
||||
// 记录具体群系
|
||||
String bioKey =
|
||||
"biome_" + rawBiomeKey;
|
||||
@@ -2220,12 +2585,61 @@ public class QuestTracker {
|
||||
String condKey =
|
||||
stripOptional(cond);
|
||||
if (!condKey
|
||||
.contains("打卡")
|
||||
|| !condKey
|
||||
.contains("群系"))
|
||||
.contains("打卡"))
|
||||
continue;
|
||||
// 支持群系和结构两种打卡条件
|
||||
boolean isBiomeCond =
|
||||
condKey.contains("群系")
|
||||
|| condKey
|
||||
.contains("风景点");
|
||||
boolean isStructureCond =
|
||||
condKey.contains("结构")
|
||||
|| condKey
|
||||
.contains("村庄")
|
||||
|| condKey
|
||||
.contains("神殿")
|
||||
|| condKey
|
||||
.contains("神庙")
|
||||
|| condKey
|
||||
.contains("小屋")
|
||||
|| condKey
|
||||
.contains("雪屋")
|
||||
|| condKey
|
||||
.contains("府邸")
|
||||
|| condKey
|
||||
.contains("前哨站")
|
||||
|| condKey
|
||||
.contains("矿井")
|
||||
|| condKey
|
||||
.contains("要塞")
|
||||
|| condKey
|
||||
.contains("城市")
|
||||
|| condKey
|
||||
.contains("废墟")
|
||||
|| condKey
|
||||
.contains("密室")
|
||||
|| condKey
|
||||
.contains("沉船")
|
||||
|| condKey
|
||||
.contains("宝箱")
|
||||
|| condKey
|
||||
.contains("传送门")
|
||||
|| condKey
|
||||
.contains("化石")
|
||||
|| condKey
|
||||
.contains("堡垒");
|
||||
if (!isBiomeCond
|
||||
&& !isStructureCond)
|
||||
continue;
|
||||
String target =
|
||||
extractBiome(condKey);
|
||||
plugin.getLogger().info(
|
||||
"[QuestTracker] 条件匹配: condKey=\""
|
||||
+ condKey + "\", target=\""
|
||||
+ target + "\", category=\""
|
||||
+ category + "\", isBiomeCond="
|
||||
+ isBiomeCond + ", isStructureCond="
|
||||
+ isStructureCond);
|
||||
boolean hit = false;
|
||||
if ("ALL".equals(target)
|
||||
|| "风景点"
|
||||
@@ -2272,6 +2686,7 @@ public class QuestTracker {
|
||||
// ===== 获取群系和分类 =====
|
||||
String biome = "minecraft:plains";
|
||||
String category = "平原";
|
||||
String structureCat = null;
|
||||
try {
|
||||
org.bukkit.World w =
|
||||
Bukkit.getWorld(worldName);
|
||||
@@ -2288,12 +2703,29 @@ public class QuestTracker {
|
||||
category = mapBiomeCategory(
|
||||
biome, worldName);
|
||||
|
||||
int surfaceY = w
|
||||
.getHighestBlockYAt(
|
||||
(int) Math.floor(x),
|
||||
(int) Math.floor(z));
|
||||
if (y < surfaceY - 8) {
|
||||
category = "地下";
|
||||
// ===== 结构检测优先于Y轴"地下"判断 =====
|
||||
structureCat =
|
||||
detectStructureAt(
|
||||
worldName, x, y, z);
|
||||
plugin.getLogger().info(
|
||||
"[QuestTracker] checkInAtPosition 结构检测: "
|
||||
+ structureCat + " at "
|
||||
+ String.format("%.0f,%.0f,%.0f", x, y, z));
|
||||
|
||||
if (structureCat != null
|
||||
&& !structureCat.isEmpty()
|
||||
&& !"地下".equals(structureCat)) {
|
||||
// 结构检测成功且非"地下"类,结构分类优先
|
||||
category = structureCat;
|
||||
} else {
|
||||
// 未检测到有效结构,使用Y轴检测判定地下
|
||||
int surfaceY = w
|
||||
.getHighestBlockYAt(
|
||||
(int) Math.floor(x),
|
||||
(int) Math.floor(z));
|
||||
if (y < surfaceY - 8) {
|
||||
category = "地下";
|
||||
}
|
||||
}
|
||||
|
||||
if ("下界".equals(category)
|
||||
@@ -2338,6 +2770,9 @@ public class QuestTracker {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("§a打卡成功!群系: §e")
|
||||
.append(category);
|
||||
if (structureCat != null && !structureCat.isEmpty()) {
|
||||
sb.append("\n§a结构: §e").append(structureCat);
|
||||
}
|
||||
if (!matched.isEmpty()) {
|
||||
sb.append("\n§a完成任务条件:");
|
||||
for (String m : matched) {
|
||||
@@ -2592,9 +3027,19 @@ public class QuestTracker {
|
||||
* "打卡任意海洋群系" → "海洋"
|
||||
* "打卡任意群系" → "ALL"
|
||||
* "打卡任意风景点" → ""
|
||||
* "打卡村庄" → "村庄"
|
||||
* "打卡沙漠神殿" → "沙漠"
|
||||
*/
|
||||
private String extractBiome(String text) {
|
||||
if (text == null) return "";
|
||||
|
||||
// 优先匹配结构名称(更具体)
|
||||
String structureResult =
|
||||
mapStructureCategory(text);
|
||||
if (!structureResult.isEmpty()) {
|
||||
return structureResult;
|
||||
}
|
||||
|
||||
// 逐个匹配分类关键词
|
||||
for (Map.Entry<String, String> en
|
||||
: CATEGORY_KEYWORDS.entrySet()) {
|
||||
@@ -2742,22 +3187,43 @@ public class QuestTracker {
|
||||
|
||||
if (key.contains("打卡")
|
||||
&& (key.contains("群系")
|
||||
|| key.contains("风景点"))) {
|
||||
|| key.contains("风景点")
|
||||
|| key.contains("结构")
|
||||
|| key.contains("村庄")
|
||||
|| key.contains("神殿")
|
||||
|| key.contains("神庙")
|
||||
|| key.contains("小屋")
|
||||
|| key.contains("雪屋")
|
||||
|| key.contains("府邸")
|
||||
|| key.contains("前哨站")
|
||||
|| key.contains("矿井")
|
||||
|| key.contains("要塞")
|
||||
|| key.contains("城市")
|
||||
|| key.contains("废墟")
|
||||
|| key.contains("密室")
|
||||
|| key.contains("废墟")
|
||||
|| key.contains("沉船")
|
||||
|| key.contains("宝箱")
|
||||
|| key.contains("传送门")
|
||||
|| key.contains("化石")
|
||||
|| key.contains("堡垒"))) {
|
||||
String biome =
|
||||
extractBiome(key);
|
||||
int needed = parseNumberFromText(key);
|
||||
if (needed <= 0) needed = 1;
|
||||
if ("ALL".equals(biome)) {
|
||||
int total = getCounter(
|
||||
player, "biome_ALL");
|
||||
return total >= 1;
|
||||
return total >= needed;
|
||||
}
|
||||
if (!biome.isEmpty()) {
|
||||
int catCount = getCounter(
|
||||
player,
|
||||
"biomecat_" + biome);
|
||||
return catCount >= 1;
|
||||
return catCount >= needed;
|
||||
}
|
||||
return getCounter(
|
||||
player, "biome_ALL") >= 1;
|
||||
player, "biome_ALL") >= needed;
|
||||
}
|
||||
|
||||
if (key.contains("完成")
|
||||
@@ -2817,7 +3283,12 @@ public class QuestTracker {
|
||||
Object val =
|
||||
getDb().getField(
|
||||
player,
|
||||
"checkin_streak");
|
||||
"login_streak");
|
||||
if (val == null || (val instanceof Number && ((Number) val).intValue() == 0)) {
|
||||
val = getDb().getField(
|
||||
player,
|
||||
"checkin_streak");
|
||||
}
|
||||
int streak = val instanceof Number
|
||||
? ((Number) val).intValue()
|
||||
: 0;
|
||||
@@ -2906,7 +3377,12 @@ public class QuestTracker {
|
||||
if (needed <= 0) needed = 1;
|
||||
int streak = getIntField(
|
||||
player,
|
||||
"checkin_streak");
|
||||
"login_streak");
|
||||
if (streak == 0) {
|
||||
streak = getIntField(
|
||||
player,
|
||||
"checkin_streak");
|
||||
}
|
||||
if (streak == 0) {
|
||||
streak = getIntField(
|
||||
player, "streak");
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.scheduler.BukkitRunnable;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 快速返回系统 (/back)
|
||||
* - 每分钟记录一次在线玩家的坐标
|
||||
* - /back 命令传送玩家到最近的一个记录坐标
|
||||
*/
|
||||
public class QuickBack implements Listener, CommandExecutor, TabCompleter {
|
||||
private final Main plugin;
|
||||
|
||||
// 内存缓存: player_name → Location (每分钟刷新)
|
||||
private final Map<String, Location> lastKnownLocations = new ConcurrentHashMap<>();
|
||||
|
||||
// 冷却: player_name → next_available_time
|
||||
private final Map<String, Long> cooldowns = new ConcurrentHashMap<>();
|
||||
|
||||
// ★ 定时器引用(用于 shutdown 时取消)
|
||||
private BukkitTask scheduleTask = null;
|
||||
|
||||
private static final long COOLDOWN_MS = 10 * 1000; // 10秒冷却
|
||||
|
||||
public QuickBack(Main plugin) {
|
||||
this.plugin = plugin;
|
||||
loadAllFromDB();
|
||||
|
||||
// 每分钟记录所有在线玩家的坐标
|
||||
scheduleTask = new BukkitRunnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
for (Player p : Bukkit.getOnlinePlayers()) {
|
||||
recordLocation(p);
|
||||
}
|
||||
}
|
||||
}.runTaskTimer(plugin, 1200L, 1200L);
|
||||
}
|
||||
|
||||
// ==================== 事件监听 ====================
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onPlayerMove(PlayerMoveEvent event) {
|
||||
// 移动超过8格时才记录(减少数据库写入压力)
|
||||
Location from = event.getFrom();
|
||||
Location to = event.getTo();
|
||||
if (to == null || from.getWorld() != to.getWorld()) return;
|
||||
|
||||
double distance = from.distance(to);
|
||||
if (distance < 8.0) return;
|
||||
|
||||
recordLocation(event.getPlayer());
|
||||
}
|
||||
|
||||
// ==================== 命令处理 ====================
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (!(sender instanceof Player)) {
|
||||
sender.sendMessage("§c此命令仅限玩家使用");
|
||||
return true;
|
||||
}
|
||||
|
||||
Player player = (Player) sender;
|
||||
|
||||
// 检查冷却
|
||||
Long nextAvailable = cooldowns.get(player.getName());
|
||||
if (nextAvailable != null && System.currentTimeMillis() < nextAvailable) {
|
||||
long remaining = (nextAvailable - System.currentTimeMillis()) / 1000;
|
||||
player.sendMessage("§c快速返回冷却中,请等待 §e" + remaining + " §c秒");
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取最近的已知位置
|
||||
Location loc = getLastKnownLocation(player);
|
||||
if (loc == null) {
|
||||
player.sendMessage("§c没有找到可返回的位置记录");
|
||||
return true;
|
||||
}
|
||||
|
||||
// 传送
|
||||
if (loc.getWorld() == null) {
|
||||
player.sendMessage("§c位置世界信息无效");
|
||||
return true;
|
||||
}
|
||||
|
||||
// ★ 禁止/back传送至PVP测试世界
|
||||
String worldName = loc.getWorld().getName();
|
||||
if (worldName != null && worldName.startsWith("pvp_test_")) {
|
||||
player.sendMessage("§c§l[快速返回] §c无法返回PVP测试世界,请使用 /pvp test 进入测试场选装");
|
||||
return true;
|
||||
}
|
||||
|
||||
Location dest = loc.clone().add(0.5, 0, 0.5); // 对齐格子中心
|
||||
player.teleport(dest);
|
||||
|
||||
// 设置冷却
|
||||
cooldowns.put(player.getName(), System.currentTimeMillis() + COOLDOWN_MS);
|
||||
player.sendMessage("§a§l[快速返回] §f已传送到最近记录点");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== 位置记录 ====================
|
||||
|
||||
private void recordLocation(Player player) {
|
||||
Location loc = player.getLocation();
|
||||
lastKnownLocations.put(player.getName(), loc);
|
||||
// ★ 读坐标/更新内存缓存留主线程;写库是阻塞 DB 操作 -> 异步
|
||||
final String name = player.getName();
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
try {
|
||||
saveToDB(name, loc);
|
||||
} catch (Throwable t) {
|
||||
plugin.getLogger().warning("[快捷返回] 坐标落库失败 (" + name + "): " + t.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Location getLastKnownLocation(Player player) {
|
||||
// 优先从内存获取
|
||||
Location loc = lastKnownLocations.get(player.getName());
|
||||
if (loc != null && loc.getWorld() != null) {
|
||||
return loc;
|
||||
}
|
||||
|
||||
// 从数据库获取
|
||||
return loadFromDB(player.getName());
|
||||
}
|
||||
|
||||
// ==================== 数据库操作 ====================
|
||||
|
||||
private void loadAllFromDB() {
|
||||
try {
|
||||
java.sql.Connection conn = plugin.getDb().getConnection();
|
||||
if (conn == null) return;
|
||||
|
||||
PreparedStatement ps = conn.prepareStatement(
|
||||
"SELECT player_name, world, x, y, z FROM quick_back_records");
|
||||
ResultSet rs = ps.executeQuery();
|
||||
|
||||
while (rs.next()) {
|
||||
String playerName = rs.getString("player_name");
|
||||
Location loc = new Location(
|
||||
Bukkit.getWorld(rs.getString("world")),
|
||||
rs.getDouble("x"),
|
||||
rs.getDouble("y"),
|
||||
rs.getDouble("z")
|
||||
);
|
||||
lastKnownLocations.put(playerName, loc);
|
||||
}
|
||||
rs.close();
|
||||
ps.close();
|
||||
} catch (SQLException e) {
|
||||
// 表可能不存在
|
||||
}
|
||||
}
|
||||
|
||||
private void saveToDB(String playerName, Location loc) {
|
||||
try {
|
||||
java.sql.Connection conn = plugin.getDb().getConnection();
|
||||
if (conn == null) return;
|
||||
|
||||
PreparedStatement ps = conn.prepareStatement(
|
||||
"INSERT OR REPLACE INTO quick_back_records (player_name, world, x, y, z) VALUES (?, ?, ?, ?, ?)");
|
||||
ps.setString(1, playerName);
|
||||
ps.setString(2, loc.getWorld() != null ? loc.getWorld().getName() : "world");
|
||||
ps.setDouble(3, loc.getX());
|
||||
ps.setDouble(4, loc.getY());
|
||||
ps.setDouble(5, loc.getZ());
|
||||
ps.executeUpdate();
|
||||
ps.close();
|
||||
} catch (SQLException e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
private Location loadFromDB(String playerName) {
|
||||
try {
|
||||
java.sql.Connection conn = plugin.getDb().getConnection();
|
||||
if (conn == null) return null;
|
||||
|
||||
PreparedStatement ps = conn.prepareStatement(
|
||||
"SELECT world, x, y, z FROM quick_back_records WHERE player_name=? LIMIT 1");
|
||||
ps.setString(1, playerName);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
|
||||
if (rs.next()) {
|
||||
Location loc = new Location(
|
||||
Bukkit.getWorld(rs.getString("world")),
|
||||
rs.getDouble("x"),
|
||||
rs.getDouble("y"),
|
||||
rs.getDouble("z")
|
||||
);
|
||||
rs.close();
|
||||
ps.close();
|
||||
return loc;
|
||||
}
|
||||
rs.close();
|
||||
ps.close();
|
||||
} catch (SQLException e) {
|
||||
// 忽略
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== Tab补全 ====================
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
public void initTable() {
|
||||
try {
|
||||
java.sql.Connection conn = plugin.getDb().getConnection();
|
||||
if (conn == null) return;
|
||||
|
||||
java.sql.Statement st = conn.createStatement();
|
||||
st.execute("CREATE TABLE IF NOT EXISTS quick_back_records ("
|
||||
+ "player_name TEXT PRIMARY KEY, "
|
||||
+ "world TEXT, "
|
||||
+ "x REAL, y REAL, z REAL)");
|
||||
st.close();
|
||||
} catch (SQLException e) {
|
||||
plugin.getLogger().warning("[快速返回] 建表失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 关闭 ====================
|
||||
|
||||
public void shutdown() {
|
||||
if (scheduleTask != null) {
|
||||
scheduleTask.cancel();
|
||||
}
|
||||
lastKnownLocations.clear();
|
||||
cooldowns.clear();
|
||||
}
|
||||
}
|
||||
@@ -80,11 +80,11 @@ public class RadioDownloadListener
|
||||
log.warning("[Radio] "
|
||||
+ p.getName()
|
||||
+ " ✗ 拒绝了资源包");
|
||||
log.warning("[Radio] "
|
||||
/* log.warning("[Radio] "
|
||||
+ "可能原因: "
|
||||
+ "1.客户端设置禁用资源包"
|
||||
+ " 2.已缓存拒绝记录"
|
||||
+ " 3.SSL证书不匹配");
|
||||
+ " 3.SSL证书不匹配");*/
|
||||
handleFailure(p, uuid);
|
||||
break;
|
||||
|
||||
@@ -92,12 +92,12 @@ public class RadioDownloadListener
|
||||
log.warning("[Radio] "
|
||||
+ p.getName()
|
||||
+ " ✗ 下载失败");
|
||||
log.warning("[Radio] "
|
||||
/* log.warning("[Radio] "
|
||||
+ "可能原因: "
|
||||
+ "1.URL无法访问"
|
||||
+ " 2.SSL证书错误"
|
||||
+ " 3.网络超时"
|
||||
+ " 4.文件不存在(404)");
|
||||
+ " 4.文件不存在(404)");*/
|
||||
handleFailure(p, uuid);
|
||||
break;
|
||||
|
||||
@@ -124,8 +124,8 @@ public class RadioDownloadListener
|
||||
+ "玩家继续留在服务器");
|
||||
// 不踢出,只记录熔断,玩家正常游玩
|
||||
p.sendMessage("§e§l[Radio] 资源包加载失败"
|
||||
+ ",跳过资源包,部分画面、音频可能出现问题"
|
||||
+ "§d§l若想继续下载,请退出重进");
|
||||
+ ",已取消下载资源包,部分画面音频可能出现问题"
|
||||
+ "\n§d§l若想继续下载,请退出重进。若持续失败,请上报管理员");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.Material;
|
||||
import java.util.UUID;
|
||||
|
||||
public class RefundRecord {
|
||||
public final UUID playerUUID;
|
||||
public final String playerName;
|
||||
public final String itemId;
|
||||
public final String itemName;
|
||||
public final Material material;
|
||||
public final Material shulkerMaterial;
|
||||
public int amount;
|
||||
public int totalPaid;
|
||||
public final long timestamp;
|
||||
|
||||
// 普通购买
|
||||
public RefundRecord(UUID uuid, String name,
|
||||
String itemId, String itemName,
|
||||
Material material, int amount,
|
||||
int totalPaid, long timestamp) {
|
||||
this.playerUUID = uuid;
|
||||
this.playerName = name;
|
||||
this.itemId = itemId;
|
||||
this.itemName = itemName;
|
||||
this.material = material;
|
||||
this.shulkerMaterial = null;
|
||||
this.amount = amount;
|
||||
this.totalPaid = totalPaid;
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
|
||||
// 盒装购买
|
||||
public RefundRecord(UUID uuid, String name,
|
||||
String itemId, String itemName,
|
||||
Material material,
|
||||
Material shulkerMat,
|
||||
int amount, int totalPaid,
|
||||
long timestamp) {
|
||||
this.playerUUID = uuid;
|
||||
this.playerName = name;
|
||||
this.itemId = itemId;
|
||||
this.itemName = itemName;
|
||||
this.material = material;
|
||||
this.shulkerMaterial = shulkerMat;
|
||||
this.amount = amount;
|
||||
this.totalPaid = totalPaid;
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
|
||||
public boolean isExpired() {
|
||||
return System.currentTimeMillis()
|
||||
- timestamp > 300000L;
|
||||
}
|
||||
|
||||
public int getSecondsLeft() {
|
||||
long left = 300000L
|
||||
- (System.currentTimeMillis()
|
||||
- timestamp);
|
||||
return (int) Math.max(0, left / 1000);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.BanList;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.Date;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 风控管理器:检测异常登录行为,记录尝试次数,触发安全报警
|
||||
* 风控管理器:密码错误阈值封禁 + 报警邮件
|
||||
*
|
||||
* 功能1:密码错误达到阈值 → 自动封禁账号N分钟 + 推送报警邮件
|
||||
* 功能2:封禁期间拒绝所有登录尝试
|
||||
*/
|
||||
public class RiskControlManager {
|
||||
|
||||
@@ -17,232 +22,261 @@ public class RiskControlManager {
|
||||
private final Map<String, Integer> failAttempts =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
// 每个IP -> 关联的可疑玩家列表
|
||||
private final Map<String, Set<String>> ipSuspicious =
|
||||
// 每个玩家名 -> 封禁到期时间戳(毫秒)
|
||||
private final Map<String, Long> banUntil =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
// 每个玩家 -> 上次验证时间(用于冷却)
|
||||
private final Map<String, Long> verifyCooldown =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
// 配置项
|
||||
private static final int MAX_FAIL_ATTEMPTS = 5;
|
||||
private static final int MAX_ACCOUNTS_PER_IP = 5;
|
||||
private static final long COOLDOWN_MS = 60000L;
|
||||
|
||||
public RiskControlManager(Main plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次失败的登录尝试
|
||||
*
|
||||
* @param playerName 玩家名
|
||||
* @return 剩余尝试次数
|
||||
* 获取当前密码错误阈值(从配置读取)
|
||||
*/
|
||||
public int recordFailAttempt(String playerName) {
|
||||
int current = failAttempts
|
||||
.getOrDefault(playerName, 0) + 1;
|
||||
failAttempts.put(playerName, current);
|
||||
return Math.max(0, MAX_FAIL_ATTEMPTS - current);
|
||||
private int getMaxFailAttempts() {
|
||||
return plugin.getConfig2().passwordFailThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置指定玩家的失败计数(登录成功时调用)
|
||||
* 获取封禁时长(毫秒)(从配置读取)
|
||||
*/
|
||||
public void resetFailAttempts(String playerName) {
|
||||
private long getBanDurationMs() {
|
||||
return (long) plugin.getConfig2().passwordBanDuration * 1000L;
|
||||
}
|
||||
|
||||
// ==================== 封禁检查 ====================
|
||||
|
||||
/**
|
||||
* 检查玩家是否已被封禁
|
||||
* @return true=已被封禁(应拒绝登录)
|
||||
*/
|
||||
public boolean isBanned(String playerName) {
|
||||
Long until = banUntil.get(playerName);
|
||||
if (until == null) return false;
|
||||
if (System.currentTimeMillis() < until) {
|
||||
return true; // 仍在封禁期内
|
||||
}
|
||||
// 封禁已过期,自动解除
|
||||
banUntil.remove(playerName);
|
||||
failAttempts.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定玩家的失败次数
|
||||
*/
|
||||
public int getFailAttempts(String playerName) {
|
||||
return failAttempts
|
||||
.getOrDefault(playerName, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断玩家是否已被锁定(失败次数达到上限)
|
||||
*/
|
||||
public boolean isLocked(String playerName) {
|
||||
return failAttempts
|
||||
.getOrDefault(playerName, 0)
|
||||
>= MAX_FAIL_ATTEMPTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查IP是否达到注册上限
|
||||
* 直接查数据库,不依赖 DatabaseManager 的方法
|
||||
*/
|
||||
public boolean canRegisterFromIP(String ip) {
|
||||
if (ip == null || ip.isEmpty()) return true;
|
||||
try {
|
||||
java.sql.Connection conn =
|
||||
plugin.getDb().getDb();
|
||||
if (conn == null) return true;
|
||||
java.sql.PreparedStatement ps =
|
||||
conn.prepareStatement(
|
||||
"SELECT COUNT(*) FROM users "
|
||||
+ "WHERE ip_address = ?");
|
||||
ps.setString(1, ip);
|
||||
java.sql.ResultSet rs = ps.executeQuery();
|
||||
int count = 0;
|
||||
if (rs.next()) count = rs.getInt(1);
|
||||
rs.close();
|
||||
ps.close();
|
||||
return count < 5;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 记录IP关联的可疑行为
|
||||
*/
|
||||
public void recordSuspiciousIP(String ip,
|
||||
String playerName) {
|
||||
if (ip == null || ip.isEmpty()) return;
|
||||
ipSuspicious
|
||||
.computeIfAbsent(ip,
|
||||
k -> ConcurrentHashMap.newKeySet())
|
||||
.add(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某IP关联的所有可疑玩家
|
||||
*/
|
||||
public Set<String> getSuspiciousAccounts(String ip) {
|
||||
return ipSuspicious
|
||||
.getOrDefault(ip, Collections.emptySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否在验证冷却期内
|
||||
*/
|
||||
public boolean isOnCooldown(String playerName) {
|
||||
Long last = verifyCooldown.get(playerName);
|
||||
if (last == null) return false;
|
||||
return System.currentTimeMillis() - last
|
||||
< COOLDOWN_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置验证冷却时间
|
||||
*/
|
||||
public void setCooldown(String playerName) {
|
||||
verifyCooldown.put(playerName,
|
||||
System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* 当检测到可疑登录时触发报警
|
||||
* - 全服广播
|
||||
* - 有邮箱则发邮件
|
||||
* - 记录到数据库
|
||||
*/
|
||||
public void triggerAlert(Player player,
|
||||
String suspiciousIP) {
|
||||
String name = player.getName();
|
||||
|
||||
// 记录到数据库
|
||||
plugin.getDb().recordSecurityAlert(
|
||||
name, suspiciousIP);
|
||||
|
||||
// 记录IP可疑行为
|
||||
recordSuspiciousIP(suspiciousIP, name);
|
||||
|
||||
// 全服广播
|
||||
String serverMsg = "§c§l[安全警告] §e" + name
|
||||
+ " §f的账号疑似遭到 §c" + suspiciousIP
|
||||
+ " §f的盗号尝试, 请各位玩家注意账号安全!";
|
||||
Bukkit.broadcastMessage(serverMsg);
|
||||
|
||||
// 有邮箱则发邮件
|
||||
String emailAddr = (String) plugin.getDb()
|
||||
.getField(name, "email");
|
||||
if (emailAddr != null
|
||||
&& !emailAddr.isEmpty()) {
|
||||
String subject =
|
||||
"[安全警告] 您的账号疑似遭到盗号尝试";
|
||||
String body = "尊敬的玩家 " + name
|
||||
+ ":\n\n"
|
||||
+ "您的账号在 "
|
||||
+ new java.text.SimpleDateFormat(
|
||||
"yyyy-MM-dd HH:mm:ss")
|
||||
.format(new java.util.Date())
|
||||
+ " 疑似遭到 IP: " + suspiciousIP
|
||||
+ " 的盗号尝试。\n\n"
|
||||
+ "建议您:\n"
|
||||
+ "1. 立即修改密码"
|
||||
+ "(使用 /sdf1_login pw)\n"
|
||||
+ "2. 如非本人操作,"
|
||||
+ "请联系管理员处理\n\n"
|
||||
+ "--Sdf1_login 安全系统";
|
||||
plugin.getEmail().sendTempPassword(
|
||||
emailAddr, name, body);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理登录失败时的风控逻辑
|
||||
*
|
||||
* @param player 玩家
|
||||
* @param suspiciousIP 可疑来源IP
|
||||
* @return true=已被锁定(应踢出), false=仍可继续尝试
|
||||
*/
|
||||
public boolean handleLoginFailure(Player player,
|
||||
String suspiciousIP) {
|
||||
String name = player.getName();
|
||||
int remaining = recordFailAttempt(name);
|
||||
|
||||
if (remaining <= 0) {
|
||||
// 达到上限,触发报警
|
||||
triggerAlert(player, suspiciousIP);
|
||||
player.kickPlayer(
|
||||
"§c§l[风控] §f登录失败次数过多,"
|
||||
+ " 账号已被临时锁定, 请联系管理员");
|
||||
return true;
|
||||
}
|
||||
|
||||
// 未达上限,提示剩余次数
|
||||
player.sendMessage(
|
||||
"§c§l[风控] §f密码错误! "
|
||||
+ "剩余" + remaining + "次机会");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取玩家剩余封禁时间(秒),0=未封禁
|
||||
*/
|
||||
public int getBanRemainingSeconds(String playerName) {
|
||||
Long until = banUntil.get(playerName);
|
||||
if (until == null) return 0;
|
||||
long remaining = until - System.currentTimeMillis();
|
||||
if (remaining <= 0) {
|
||||
banUntil.remove(playerName);
|
||||
failAttempts.remove(playerName);
|
||||
return 0;
|
||||
}
|
||||
return (int) (remaining / 1000) + 1; // 向上取整
|
||||
}
|
||||
|
||||
// ==================== 失败记录 ====================
|
||||
|
||||
/**
|
||||
* 记录一次失败的登录尝试,判断是否触发封禁
|
||||
*
|
||||
* @param player 登录失败的玩家
|
||||
* @return 剩余尝试次数(-1=已被封禁)
|
||||
*/
|
||||
public int recordFailAttempt(Player player) {
|
||||
String name = player.getName();
|
||||
int maxAttempts = getMaxFailAttempts();
|
||||
|
||||
// 已被封禁,直接返回
|
||||
if (isBanned(name)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int current = failAttempts
|
||||
.getOrDefault(name, 0) + 1;
|
||||
failAttempts.put(name, current);
|
||||
|
||||
int remaining = Math.max(0, maxAttempts - current);
|
||||
|
||||
// 达到阈值 → 触发封禁
|
||||
if (current >= maxAttempts) {
|
||||
triggerBan(player);
|
||||
}
|
||||
|
||||
return remaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发封禁:Bukkit真实封禁 + 记录时间 + 发邮件 + 踢出
|
||||
* 双层封禁:Bukkit ban阻止连接加入,内存计时器到期自动解封
|
||||
*/
|
||||
private void triggerBan(Player player) {
|
||||
String name = player.getName();
|
||||
long durationMs = getBanDurationMs();
|
||||
long until = System.currentTimeMillis() + durationMs;
|
||||
banUntil.put(name, until);
|
||||
|
||||
String ip = plugin.getPlayerIP(player);
|
||||
int banMinutes = (int) (durationMs / 60000);
|
||||
|
||||
plugin.getLogger().warning(
|
||||
"[风控] 玩家 " + name
|
||||
+ " 密码错误达到阈值("
|
||||
+ getMaxFailAttempts() + "次)"
|
||||
+ ",封禁 " + banMinutes + " 分钟");
|
||||
|
||||
// ★ 真实封禁:调用Bukkit BanList API阻止玩家重新加入服务器
|
||||
String banReason = "§c§l[安全风控] 密码连续错误"
|
||||
+ getMaxFailAttempts() + "次,"
|
||||
+ "账号临时封禁" + banMinutes + "分钟";
|
||||
try {
|
||||
// 使用 BanList API 设置带过期时间的封禁
|
||||
Date expiry = new Date(System.currentTimeMillis() + durationMs);
|
||||
Bukkit.getBanList(BanList.Type.NAME).addBan(
|
||||
name, banReason, expiry, "[Sdf1_login] 风控");
|
||||
plugin.getLogger().info(
|
||||
"[风控] 已通过Bukkit BanList封禁玩家 "
|
||||
+ name + " (" + banMinutes + "分钟,到期: " + expiry + ")");
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning(
|
||||
"[风控] BanList封禁失败: " + e.getMessage());
|
||||
}
|
||||
// 踢出玩家(无论BanList是否成功,立即踢出)
|
||||
player.kickPlayer(banReason);
|
||||
|
||||
// 异步发送报警邮件
|
||||
sendBanAlertEmail(name, ip, banMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送封禁报警邮件到玩家绑定的邮箱
|
||||
* 未绑定邮箱则跳过
|
||||
*/
|
||||
private void sendBanAlertEmail(String playerName,
|
||||
String ip, int banMinutes) {
|
||||
String emailAddr = (String) plugin.getDb()
|
||||
.getField(playerName, "email");
|
||||
if (emailAddr == null || emailAddr.isEmpty()) {
|
||||
plugin.getLogger().info(
|
||||
"[风控] 玩家 " + playerName
|
||||
+ " 未绑定邮箱,跳过报警邮件");
|
||||
return;
|
||||
}
|
||||
|
||||
String subject =
|
||||
"[安全警告] 您的账号已被临时封禁";
|
||||
String body = "尊敬的玩家 " + playerName + ":\n\n"
|
||||
+ "您的账号在 "
|
||||
+ new java.text.SimpleDateFormat(
|
||||
"yyyy-MM-dd HH:mm:ss")
|
||||
.format(new java.util.Date())
|
||||
+ " 因密码连续错误 "
|
||||
+ getMaxFailAttempts() + " 次"
|
||||
+ ",已被自动封禁 " + banMinutes + " 分钟。\n\n"
|
||||
+ "来源IP: " + (ip != null ? ip : "未知") + "\n"
|
||||
+ "封禁到期: "
|
||||
+ new java.text.SimpleDateFormat(
|
||||
"yyyy-MM-dd HH:mm:ss")
|
||||
.format(new java.util.Date(
|
||||
System.currentTimeMillis()
|
||||
+ (long) banMinutes * 60000L))
|
||||
+ "\n\n"
|
||||
+ "如果这是您本人操作,请等待封禁到期后重试。\n"
|
||||
+ "如果非本人操作,建议您立即修改密码"
|
||||
+ "(使用 /sdf1_login pw)。\n\n"
|
||||
+ "--Sdf1_login 安全系统";
|
||||
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
try {
|
||||
plugin.getEmail().sendBody(
|
||||
emailAddr, subject, body);
|
||||
plugin.getLogger().info(
|
||||
"[风控] 封禁报警邮件已发送到 "
|
||||
+ emailAddr);
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning(
|
||||
"[风控] 报警邮件发送失败: "
|
||||
+ e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 成功重置 ====================
|
||||
|
||||
/**
|
||||
* 登录成功后重置风控状态
|
||||
*/
|
||||
public void onLoginSuccess(String playerName) {
|
||||
resetFailAttempts(playerName);
|
||||
failAttempts.remove(playerName);
|
||||
banUntil.remove(playerName);
|
||||
}
|
||||
|
||||
// ==================== 清理 ====================
|
||||
|
||||
/**
|
||||
* 清理过期的冷却记录(可由定时任务调用)
|
||||
* 清理过期的封禁记录(可由定时任务调用)
|
||||
* 同步解除过期玩家的Bukkit封禁
|
||||
*/
|
||||
public void cleanup() {
|
||||
long now = System.currentTimeMillis();
|
||||
verifyCooldown.entrySet().removeIf(
|
||||
e -> now - e.getValue() > COOLDOWN_MS * 10);
|
||||
// 收集过期的玩家名
|
||||
List<String> expired = new ArrayList<>();
|
||||
banUntil.entrySet().removeIf(e -> {
|
||||
if (e.getValue() < now) {
|
||||
expired.add(e.getKey());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// 同步解除Bukkit封禁
|
||||
for (String name : expired) {
|
||||
try {
|
||||
Bukkit.getBanList(BanList.Type.NAME)
|
||||
.pardon(name);
|
||||
plugin.getLogger().info(
|
||||
"[风控] 封禁到期,已解除Bukkit封禁: "
|
||||
+ name);
|
||||
} catch (Exception ignored) {}
|
||||
failAttempts.remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有玩家的失败尝试记录(调试用)
|
||||
* 获取所有封禁记录(调试/管理用)
|
||||
*/
|
||||
public Map<String, Long> getAllBans() {
|
||||
return Collections.unmodifiableMap(banUntil);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有失败尝试记录(调试用)
|
||||
*/
|
||||
public Map<String, Integer> getAllFailAttempts() {
|
||||
return Collections.unmodifiableMap(failAttempts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动解除某玩家的封禁(管理员用)
|
||||
*/
|
||||
public void unban(String playerName) {
|
||||
banUntil.remove(playerName);
|
||||
failAttempts.remove(playerName);
|
||||
// 同步解除Bukkit封禁
|
||||
try {
|
||||
Bukkit.getBanList(BanList.Type.NAME)
|
||||
.pardon(playerName);
|
||||
plugin.getLogger().info("[风控] 已解除Bukkit封禁: "
|
||||
+ playerName);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有风控数据(管理员调试用)
|
||||
*/
|
||||
public void clearAll() {
|
||||
failAttempts.clear();
|
||||
ipSuspicious.clear();
|
||||
verifyCooldown.clear();
|
||||
banUntil.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.scheduler.BukkitRunnable;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class SalesStatsManager {
|
||||
|
||||
private final Main plugin;
|
||||
|
||||
private final Map<String, Integer> sessionSales =
|
||||
new ConcurrentHashMap<>();
|
||||
private int sessionRevenue = 0;
|
||||
private int sessionOrders = 0;
|
||||
private int sessionItems = 0;
|
||||
private int sessionRefunds = 0;
|
||||
private int sessionRefundAmount = 0;
|
||||
private long sessionStart = System.currentTimeMillis();
|
||||
|
||||
// 状态
|
||||
private boolean waitingForEmpty = false;
|
||||
private boolean hasReported = false;
|
||||
private BukkitRunnable waitTask = null;
|
||||
|
||||
public SalesStatsManager(Main plugin) {
|
||||
this.plugin = plugin;
|
||||
startMonitor();
|
||||
}
|
||||
|
||||
// ★ 每10秒检测一次
|
||||
private void startMonitor() {
|
||||
new BukkitRunnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
int online = Bukkit.getOnlinePlayers().size();
|
||||
|
||||
if (online > 0) {
|
||||
// 有玩家在线 → 重置等待状态
|
||||
if (waitingForEmpty) {
|
||||
// 刚有人进来了,清除已汇报标记
|
||||
// 下次没人时会重新汇报
|
||||
}
|
||||
waitingForEmpty = false;
|
||||
hasReported = false;
|
||||
cancelWaitTask();
|
||||
} else if (!waitingForEmpty && !hasReported) {
|
||||
// 没人了,且还没汇报过 → 开始1分钟倒计时
|
||||
waitingForEmpty = true;
|
||||
startWaitCountdown();
|
||||
}
|
||||
}
|
||||
}.runTaskTimer(plugin, 200L, 200L);
|
||||
}
|
||||
|
||||
// ★ 1分钟等待
|
||||
private void startWaitCountdown() {
|
||||
cancelWaitTask();
|
||||
waitTask = new BukkitRunnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
int online = Bukkit.getOnlinePlayers().size();
|
||||
|
||||
if (online > 0) {
|
||||
// 等待期间有人进来 → 取消,等下次没人
|
||||
waitingForEmpty = false;
|
||||
hasReported = false;
|
||||
waitTask = null;
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// 1分钟没人 → 汇报一次
|
||||
if (!hasReported) {
|
||||
hasReported = true;
|
||||
report();
|
||||
}
|
||||
waitingForEmpty = false;
|
||||
waitTask = null;
|
||||
cancel();
|
||||
}
|
||||
};
|
||||
waitTask.runTaskLater(plugin, 1200L); // 60秒
|
||||
}
|
||||
|
||||
private void cancelWaitTask() {
|
||||
if (waitTask != null) {
|
||||
waitTask.cancel();
|
||||
waitTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 记录 =====
|
||||
|
||||
public void recordSale(String itemName, int price) {
|
||||
sessionSales.merge(itemName, 1, Integer::sum);
|
||||
sessionRevenue += price;
|
||||
sessionOrders++;
|
||||
sessionItems++;
|
||||
}
|
||||
|
||||
public void recordRefund(int amount) {
|
||||
sessionRefunds++;
|
||||
sessionRefundAmount += amount;
|
||||
}
|
||||
|
||||
// ===== 汇报 =====
|
||||
|
||||
public void report() {
|
||||
if (sessionItems == 0 && sessionRefunds == 0) {
|
||||
// plugin.getLogger().info("[Sales] 无数据,跳过");
|
||||
return;
|
||||
}
|
||||
|
||||
long minutes =
|
||||
(System.currentTimeMillis() - sessionStart)
|
||||
/ 60000;
|
||||
if (minutes < 1) minutes = 1;
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("§6§l========== 销量汇总 ==========");
|
||||
sb.append("\n§7时长: §f").append(minutes).append("分钟");
|
||||
sb.append("\n§7订单: §f").append(sessionOrders).append("笔");
|
||||
sb.append("\n§7商品: §f").append(sessionItems).append("件");
|
||||
sb.append("\n§7成交额: §6").append(sessionRevenue).append("枚");
|
||||
sb.append("\n§7退款: §c").append(sessionRefunds)
|
||||
.append("次(§c").append(sessionRefundAmount)
|
||||
.append("枚§7)");
|
||||
sb.append("\n§6§l------------------------------");
|
||||
|
||||
if (!sessionSales.isEmpty()) {
|
||||
sb.append("\n§7销量排行:");
|
||||
List<Map.Entry<String, Integer>> sorted =
|
||||
new ArrayList<>(sessionSales.entrySet());
|
||||
sorted.sort((a, b) ->
|
||||
b.getValue() - a.getValue());
|
||||
int rank = 1;
|
||||
for (Map.Entry<String, Integer> e : sorted) {
|
||||
if (rank > 10) break;
|
||||
sb.append("\n§e").append(rank).append(". ")
|
||||
.append(e.getKey())
|
||||
.append(" §7x").append(e.getValue());
|
||||
rank++;
|
||||
}
|
||||
}
|
||||
sb.append("\n§6§l==============================");
|
||||
|
||||
String msg = sb.toString();
|
||||
for (Player p : Bukkit.getOnlinePlayers()) {
|
||||
if (p.isOp()) {
|
||||
for (String line : msg.split("\n"))
|
||||
p.sendMessage(line);
|
||||
}
|
||||
}
|
||||
plugin.getLogger().info("[Sales] 汇报完成");
|
||||
resetSession();
|
||||
}
|
||||
|
||||
public void resetSession() {
|
||||
sessionSales.clear();
|
||||
sessionRevenue = 0;
|
||||
sessionOrders = 0;
|
||||
sessionItems = 0;
|
||||
sessionRefunds = 0;
|
||||
sessionRefundAmount = 0;
|
||||
sessionStart = System.currentTimeMillis();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ShopCategory {
|
||||
private final String name;
|
||||
private final String fileName;
|
||||
private final List<ShopItem> items;
|
||||
|
||||
public ShopCategory(String name, String fileName) {
|
||||
this.name = name;
|
||||
this.fileName = fileName;
|
||||
this.items = new ArrayList<>();
|
||||
}
|
||||
|
||||
public String getName() { return name; }
|
||||
public String getFileName() { return fileName; }
|
||||
public List<ShopItem> getItems() { return items; }
|
||||
|
||||
public ShopItem getItem(String id) {
|
||||
for (ShopItem item : items) {
|
||||
if (item.getId().equalsIgnoreCase(id))
|
||||
return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void addItem(ShopItem item) {
|
||||
items.add(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.Material;
|
||||
|
||||
public class ShopItem {
|
||||
private final String id;
|
||||
private final String displayName;
|
||||
private final Material material;
|
||||
private int buyPrice;
|
||||
private int sellPrice;
|
||||
private int stock;
|
||||
private int hourlySales;
|
||||
private int totalSales;
|
||||
public ShopItem(String id, String displayName,
|
||||
Material material, int buyPrice,
|
||||
int sellPrice, int stock,
|
||||
int hourlySales, int totalSales) {
|
||||
this.id = id;
|
||||
this.displayName = displayName;
|
||||
this.material = material;
|
||||
this.buyPrice = buyPrice;
|
||||
this.sellPrice = sellPrice;
|
||||
this.stock = stock;
|
||||
this.hourlySales = hourlySales;
|
||||
this.totalSales = totalSales;
|
||||
}
|
||||
|
||||
public int getEffectiveSellPrice() {
|
||||
if (sellPrice <= 0) return 0;
|
||||
return sellPrice;
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public String getDisplayName() { return displayName; }
|
||||
public Material getMaterial() { return material; }
|
||||
public int getBuyPrice() { return buyPrice; }
|
||||
public int getSellPrice() { return sellPrice; }
|
||||
public int getStock() { return stock; }
|
||||
public int getHourlySales() { return hourlySales; }
|
||||
public int getTotalSales() { return totalSales; }
|
||||
|
||||
public void setStock(int s) { this.stock = s; }
|
||||
public void setHourlySales(int s) { this.hourlySales = s; }
|
||||
public void setTotalSales(int s) { this.totalSales = s; }
|
||||
public void setBuyPrice(int p) { this.buyPrice = p; }
|
||||
public void setSellPrice(int p) { this.sellPrice = p; }
|
||||
public void addSales(int n) {
|
||||
this.hourlySales += n;
|
||||
this.totalSales += n;
|
||||
}
|
||||
}
|
||||
@@ -163,7 +163,7 @@ public class TicketManager {
|
||||
"§7类型: §f" + type,
|
||||
"§7状态: " + stxt(status),
|
||||
"§7提交者: §f" + requester,
|
||||
"§7奖励: §e" + reward + " 积分"));
|
||||
"§7奖励: §e" + reward + " 债券"));
|
||||
|
||||
g.setItem(12, mk(Material.BOOK,
|
||||
"§7详细描述",
|
||||
@@ -242,6 +242,13 @@ public class TicketManager {
|
||||
"§a标记完结",
|
||||
"§7确认工单处理完成"));
|
||||
}
|
||||
// ★ 服务商上报完结后:管理员确认完结
|
||||
if ("completed".equals(status)) {
|
||||
g.setItem(19, mk(Material.LIME_DYE,
|
||||
"§a确认完结",
|
||||
"§7确认服务商处理完成",
|
||||
"§7输入评分1-5"));
|
||||
}
|
||||
// 驳回(非已关闭/已完结状态)
|
||||
if (!"closed".equals(status)
|
||||
&& !"resolved".equals(status)
|
||||
@@ -410,7 +417,7 @@ public class TicketManager {
|
||||
"§a§l[工单] §f创建成功!"
|
||||
+ " #§e" + id
|
||||
+ " 奖励: §e"
|
||||
+ price + "积分");
|
||||
+ price + "债券");
|
||||
}
|
||||
notifyAdmins(p.getName(), id);
|
||||
} else {
|
||||
@@ -697,6 +704,11 @@ public class TicketManager {
|
||||
"§e§l===================");
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 由异步定时器调用(Main.startTicketAutoProcess)。
|
||||
* DB 查询/写回留在当前(异步)线程;
|
||||
* 取玩家 / 发消息 / 备份背包 / 踢人 一律经 sync() 回主线程。
|
||||
*/
|
||||
public void autoProcessCompleted() {
|
||||
long timeoutMs = 10 * 60 * 1000L;
|
||||
|
||||
@@ -730,25 +742,26 @@ public class TicketManager {
|
||||
.getUnconfirmedCompleted(
|
||||
timeoutMs);
|
||||
for (Map<String, Object> t : list) {
|
||||
int id = nv(t, "id");
|
||||
final int id = nv(t, "id");
|
||||
plugin.getDb().confirmTicket(id, 3);
|
||||
Player rp = Bukkit.getPlayer(
|
||||
sv(t, "requester"));
|
||||
if (rp != null && rp.isOnline())
|
||||
rp.sendMessage(
|
||||
"§e§l[工单] §f工单 #"
|
||||
+ id + " 已自动完结");
|
||||
String assigned =
|
||||
sv(t, "assigned_to");
|
||||
if (!assigned.isEmpty()) {
|
||||
Player pp =
|
||||
Bukkit.getPlayer(assigned);
|
||||
if (pp != null && pp.isOnline())
|
||||
pp.sendMessage(
|
||||
final String req = sv(t, "requester");
|
||||
final String asg = sv(t, "assigned_to");
|
||||
sync(() -> {
|
||||
Player rp = Bukkit.getPlayer(req);
|
||||
if (rp != null && rp.isOnline())
|
||||
rp.sendMessage(
|
||||
"§e§l[工单] §f工单 #"
|
||||
+ id
|
||||
+ " 已自动完结");
|
||||
}
|
||||
+ id + " 已自动完结");
|
||||
if (!asg.isEmpty()) {
|
||||
Player pp =
|
||||
Bukkit.getPlayer(asg);
|
||||
if (pp != null && pp.isOnline())
|
||||
pp.sendMessage(
|
||||
"§e§l[工单] §f工单 #"
|
||||
+ id
|
||||
+ " 已自动完结");
|
||||
}
|
||||
});
|
||||
settleTicket(id);
|
||||
}
|
||||
|
||||
@@ -758,14 +771,16 @@ public class TicketManager {
|
||||
.getRepliedStaleTickets(
|
||||
timeoutMs);
|
||||
for (Map<String, Object> t : stale) {
|
||||
int id = nv(t, "id");
|
||||
final int id = nv(t, "id");
|
||||
plugin.getDb().confirmTicket(id, 3);
|
||||
Player rp = Bukkit.getPlayer(
|
||||
sv(t, "requester"));
|
||||
if (rp != null && rp.isOnline())
|
||||
rp.sendMessage(
|
||||
"§e§l[工单] §f工单 #"
|
||||
+ id + " 超时自动完结");
|
||||
final String req = sv(t, "requester");
|
||||
sync(() -> {
|
||||
Player rp = Bukkit.getPlayer(req);
|
||||
if (rp != null && rp.isOnline())
|
||||
rp.sendMessage(
|
||||
"§e§l[工单] §f工单 #"
|
||||
+ id + " 超时自动完结");
|
||||
});
|
||||
settleTicket(id);
|
||||
}
|
||||
}
|
||||
@@ -1017,23 +1032,18 @@ public class TicketManager {
|
||||
// 管理员:标记完结(已回复状态)
|
||||
if (adm(p) && "replied".equals(st)
|
||||
&& asg.equals("admin")) {
|
||||
|
||||
// 管理员:标记完结(已回复状态)
|
||||
if (adm(p) && "replied".equals(st)
|
||||
&& asg.equals("admin")) {
|
||||
Map<String, Object> tk =
|
||||
plugin.getDb().getTicket(id);
|
||||
String tkType = sv(tk, "type");
|
||||
if (TYPE_DELETE.equals(tkType)) {
|
||||
approveDeleteTicket(p, id);
|
||||
return true;
|
||||
}
|
||||
plugin.getDb().completeTicket(id);
|
||||
p.sendMessage("§a工单 #" + id
|
||||
+ " 已标记完结");
|
||||
openDetail(p, id);
|
||||
Map<String, Object> tk =
|
||||
plugin.getDb().getTicket(id);
|
||||
String tkType = sv(tk, "type");
|
||||
if (TYPE_DELETE.equals(tkType)) {
|
||||
approveDeleteTicket(p, id);
|
||||
return true;
|
||||
}
|
||||
plugin.getDb().completeTicket(id);
|
||||
p.sendMessage("§a工单 #" + id
|
||||
+ " 已标记完结");
|
||||
openDetail(p, id);
|
||||
return true;
|
||||
}
|
||||
// 服务商:抢单
|
||||
if (plugin.getDb()
|
||||
@@ -1044,6 +1054,19 @@ public class TicketManager {
|
||||
openDetail(p, id);
|
||||
return true;
|
||||
}
|
||||
// ★ 管理员:确认服务商完结(输入评分)
|
||||
if (adm(p) && "completed".equals(st)) {
|
||||
p.closeInventory();
|
||||
plugin.getChatInput()
|
||||
.getState(p).type =
|
||||
ChatInputManager.InputType
|
||||
.TICKET_SCORE;
|
||||
plugin.getChatInput()
|
||||
.getState(p).ticketId = id;
|
||||
p.sendMessage(
|
||||
"§e输入评分1-5:");
|
||||
return true;
|
||||
}
|
||||
// 服务商:标记完结
|
||||
if (plugin.getDb()
|
||||
.isServiceProvider(p.getName())
|
||||
@@ -1251,72 +1274,73 @@ public class TicketManager {
|
||||
|
||||
// ========== 私有 ==========
|
||||
|
||||
/**
|
||||
* 把 Bukkit 操作切回主线程执行。
|
||||
* 已在主线程时立即执行(保持原调用方的同步语义)。
|
||||
* 供异步定时器路径(autoProcessCompleted / settleTicket / processDeleteAccount)使用。
|
||||
*/
|
||||
private void sync(Runnable r) {
|
||||
if (Bukkit.isPrimaryThread()) r.run();
|
||||
else Bukkit.getScheduler().runTask(plugin, r);
|
||||
}
|
||||
|
||||
private void settleTicket(int tid) {
|
||||
Map<String, Object> t =
|
||||
plugin.getDb().getTicket(tid);
|
||||
if (t == null) return;
|
||||
String requester =
|
||||
final String requester =
|
||||
sv(t, "requester");
|
||||
String assigned =
|
||||
final String assigned =
|
||||
sv(t, "assigned_to");
|
||||
int reward = nv(t, "reward_amount");
|
||||
int score = nv(t, "score");
|
||||
ConfigManager cfg =
|
||||
plugin.getConfig2();
|
||||
|
||||
// ★ 提交者获得工单奖励(债券)
|
||||
if (reward > 0) {
|
||||
plugin.getDb().addPoints(
|
||||
requester, reward);
|
||||
Player rp = Bukkit.getPlayer(
|
||||
requester);
|
||||
if (rp != null && rp.isOnline()) {
|
||||
rp.sendMessage(
|
||||
"§e§l[工单] §f获得 "
|
||||
+ reward
|
||||
+ " 积分奖励");
|
||||
}
|
||||
}
|
||||
if (!assigned.isEmpty()) {
|
||||
int pts = (int) (score
|
||||
* cfg.providerPointsPerScore);
|
||||
if (pts > 0) {
|
||||
plugin.getDb().addPoints(
|
||||
assigned, pts);
|
||||
}
|
||||
double money =
|
||||
cfg.baseEconomyReward
|
||||
+ score
|
||||
* cfg.providerEconomyPerScore;
|
||||
boolean ok = false;
|
||||
try {
|
||||
if (plugin.getEconomy() != null) {
|
||||
OfflinePlayer op =
|
||||
Bukkit.getOfflinePlayer(
|
||||
assigned);
|
||||
plugin.getEconomy()
|
||||
.depositPlayer(
|
||||
op, money);
|
||||
ok = true;
|
||||
plugin.getBonds().addBonds(
|
||||
requester, reward,
|
||||
BondManager.TX_TICKET_REWARD, "",
|
||||
"System", "工单 #" + tid + " 奖励");
|
||||
sync(() -> {
|
||||
Player rp = Bukkit.getPlayer(
|
||||
requester);
|
||||
if (rp != null && rp.isOnline()) {
|
||||
rp.sendMessage(
|
||||
"§e§l[工单] §f获得 "
|
||||
+ reward
|
||||
+ " 债券奖励");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning(
|
||||
"[Sdf1_login] 经济失败: "
|
||||
+ e.getMessage());
|
||||
}
|
||||
Player pp =
|
||||
Bukkit.getPlayer(assigned);
|
||||
if (pp != null && pp.isOnline()) {
|
||||
pp.sendMessage(
|
||||
"§a§l[工单] §f工单 #"
|
||||
+ tid
|
||||
+ " 确认完结");
|
||||
if (pts > 0)
|
||||
pp.sendMessage("§7积分: §e"
|
||||
+ pts);
|
||||
if (ok)
|
||||
pp.sendMessage("§7经济: §a$"
|
||||
+ String.format(
|
||||
"%.0f", money));
|
||||
});
|
||||
}
|
||||
|
||||
// ★ 服务商获得债券奖励
|
||||
if (!assigned.isEmpty()) {
|
||||
int bondReward = (int) (score
|
||||
* cfg.providerPointsPerScore);
|
||||
if (bondReward > 0) {
|
||||
plugin.getBonds().addBonds(
|
||||
assigned, bondReward,
|
||||
BondManager.TX_TICKET_SERVICE, "",
|
||||
"System", "工单 #" + tid
|
||||
+ " 服务奖励(评分"
|
||||
+ score + ")");
|
||||
}
|
||||
final int br = bondReward;
|
||||
sync(() -> {
|
||||
Player pp =
|
||||
Bukkit.getPlayer(assigned);
|
||||
if (pp != null && pp.isOnline()) {
|
||||
pp.sendMessage(
|
||||
"§a§l[工单] §f工单 #"
|
||||
+ tid
|
||||
+ " 确认完结");
|
||||
if (br > 0)
|
||||
pp.sendMessage("§7债券: §e"
|
||||
+ br + " 枚");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1404,36 +1428,46 @@ public class TicketManager {
|
||||
*/
|
||||
public void processDeleteAccount(int tid,
|
||||
Map<String, Object> t) {
|
||||
String requester = sv(t, "requester");
|
||||
final String requester = sv(t, "requester");
|
||||
plugin.getDb().updateTicketStatus(
|
||||
tid, "resolved");
|
||||
plugin.getDb().updateTicketField(tid,
|
||||
"admin_confirmed", 1);
|
||||
Player target =
|
||||
Bukkit.getPlayer(requester);
|
||||
if (target != null && target.isOnline()) {
|
||||
plugin.backupInventory(target);
|
||||
target.sendMessage(
|
||||
"§a§l[工单] §f您的删号请求已通过");
|
||||
target.sendMessage(
|
||||
"§7物品已备份,下次登录可取回");
|
||||
target.kickPlayer(
|
||||
"§a删号成功,物品已备份。\n"
|
||||
+ "请等待5分钟后重新注册取回物品。");
|
||||
} else {
|
||||
// 离线:仅标记,不操作数据库
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] " + requester
|
||||
+ " 删号工单已通过,等待上线执行");
|
||||
}
|
||||
// 延迟执行删号
|
||||
// ★ 备份背包 / 发消息 / 踢人 必须主线程
|
||||
sync(() -> {
|
||||
Player target =
|
||||
Bukkit.getPlayerExact(requester);
|
||||
if (target != null && target.isOnline()) {
|
||||
plugin.backupInventory(target);
|
||||
target.sendMessage(
|
||||
"§a§l[工单] §f您的删号请求已通过");
|
||||
target.sendMessage(
|
||||
"§7物品已备份,下次登录可取回");
|
||||
target.kickPlayer(
|
||||
"§a删号成功,物品已备份。\n"
|
||||
+ "请等待5分钟后重新注册取回物品。");
|
||||
} else {
|
||||
// 离线:仅标记,不操作数据库
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] " + requester
|
||||
+ " 删号工单已通过,等待上线执行");
|
||||
}
|
||||
});
|
||||
// 延迟执行删号(DB 写 -> 异步,不占主线程)
|
||||
final String name = requester;
|
||||
Bukkit.getScheduler().runTaskLater(
|
||||
Bukkit.getScheduler().runTaskLaterAsynchronously(
|
||||
plugin, () -> {
|
||||
plugin.getDb().deleteUser(name);
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] " + name
|
||||
+ " 删号工单已执行");
|
||||
int deleted =
|
||||
plugin.getDb().deleteUser(name);
|
||||
if (deleted > 0) {
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] " + name
|
||||
+ " 删号工单已执行");
|
||||
} else {
|
||||
plugin.getLogger().warning(
|
||||
"[Sdf1_login] " + name
|
||||
+ " 删号工单未执行(精准匹配下账号不存在,未误删)");
|
||||
}
|
||||
}, 100L);
|
||||
}
|
||||
|
||||
@@ -1559,7 +1593,7 @@ public class TicketManager {
|
||||
lore.add("§7提交者: §f"
|
||||
+ requester);
|
||||
lore.add("§7奖励: §e" + reward
|
||||
+ " 积分");
|
||||
+ " 债券");
|
||||
lore.add("");
|
||||
lore.add("§7点击查看详情");
|
||||
im.setLore(lore);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* Sdf1_game寻宝系统桥接类
|
||||
* 用于Sdf1_login调用Sdf1_game的clearPlayerAllClaims方法
|
||||
*/
|
||||
public class TreasureBridge {
|
||||
|
||||
private Object treasureManager;
|
||||
private Method clearPlayerAllClaimsMethod;
|
||||
private boolean hooked = false;
|
||||
|
||||
/**
|
||||
* 桥接Sdf1_game插件
|
||||
* @return 是否桥接成功
|
||||
*/
|
||||
public boolean hook() {
|
||||
Plugin sdf1_game = Bukkit.getPluginManager()
|
||||
.getPlugin("Sdf1_game");
|
||||
|
||||
if (sdf1_game == null || !sdf1_game.isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取Sdf1_game的Main实例
|
||||
Object mainInstance = sdf1_game;
|
||||
|
||||
// 获取TreasureManager实例
|
||||
treasureManager = findTreasureManager(mainInstance);
|
||||
if (treasureManager == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取clearPlayerAllClaims方法
|
||||
clearPlayerAllClaimsMethod = treasureManager.getClass()
|
||||
.getMethod("clearPlayerAllClaims", String.class);
|
||||
|
||||
hooked = true;
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空指定玩家的所有领取记录
|
||||
* @param playerName 玩家名
|
||||
* @return 清除的记录数量,失败返回-1
|
||||
*/
|
||||
public int clearPlayerAllClaims(String playerName) {
|
||||
if (!isHooked()) return -1;
|
||||
try {
|
||||
int result = (int) clearPlayerAllClaimsMethod.invoke(
|
||||
treasureManager, playerName);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找TreasureManager实例
|
||||
* @param mainInstance Sdf1_game的Main实例
|
||||
* @return TreasureManager实例
|
||||
*/
|
||||
private Object findTreasureManager(Object mainInstance) {
|
||||
Class<?> c = mainInstance.getClass();
|
||||
|
||||
// 策略1: getTreasureManager() 方法
|
||||
try {
|
||||
Method m = c.getMethod("getTreasureManager");
|
||||
Object r = m.invoke(mainInstance);
|
||||
if (r != null) return r;
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
// 策略2: treasureManager 字段
|
||||
try {
|
||||
Field f = c.getDeclaredField("treasureManager");
|
||||
f.setAccessible(true);
|
||||
Object r = f.get(mainInstance);
|
||||
if (r != null) return r;
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
// 策略3: 遍历字段按类型名查找
|
||||
try {
|
||||
for (Field f : c.getDeclaredFields()) {
|
||||
if (f.getType().getSimpleName()
|
||||
.equals("TreasureManager")) {
|
||||
f.setAccessible(true);
|
||||
Object r = f.get(mainInstance);
|
||||
if (r != null) return r;
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已桥接
|
||||
* @return 桥接状态
|
||||
*/
|
||||
public boolean isHooked() {
|
||||
return hooked;
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,18 @@ package Sdf1_login;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* 玩家正版/离线身份验证管理器
|
||||
*
|
||||
* 核心逻辑:在离线模式(online-mode=false)服务器中,所有玩家UUID都是离线格式,
|
||||
* 无法通过UUID区分正版/盗版。因此:
|
||||
* - UUID非离线格式 → 服务器在线模式,正版玩家 → autoLogin
|
||||
* - UUID离线格式 → 一律视为非正版,需手动登录或OAuth验证
|
||||
* - 正版玩家通过 /mslogin OAuth验证后,由Main.addVerifiedPremiumPlayer()标记,
|
||||
* 在onJoin检查点0直接autoLogin,不经过此验证器
|
||||
*/
|
||||
public class VerificationManager {
|
||||
|
||||
private final Main plugin;
|
||||
@@ -29,6 +33,11 @@ public class VerificationManager {
|
||||
return Bukkit.getServer().getOnlineMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步验证玩家身份
|
||||
* 注意:此方法仅用于判断UUID格式,不执行自动登录
|
||||
* 自动登录由onJoin中的检查点0/1/2控制
|
||||
*/
|
||||
public void verifyPremiumAsync(Player player,
|
||||
Consumer<Boolean> callback) {
|
||||
Thread t = new Thread(() -> doVerify(player, callback),
|
||||
@@ -50,49 +59,35 @@ public class VerificationManager {
|
||||
"[Sdf1_login] 当前UUID: " + uuid);
|
||||
|
||||
try {
|
||||
UUID offlineUuid = UUID.nameUUIDFromBytes(
|
||||
java.util.UUID offlineUuid = java.util.UUID.nameUUIDFromBytes(
|
||||
("OfflinePlayer:" + name)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
String offlineStr = offlineUuid.toString();
|
||||
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] 离线UUID: " + offlineStr);
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
|
||||
boolean isOfflineUuid =
|
||||
player.getUniqueId().equals(offlineUuid);
|
||||
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] 离线UUID: " + offlineUuid);
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] UUID匹配离线格式: "
|
||||
+ isOfflineUuid);
|
||||
|
||||
if (!isOfflineUuid) {
|
||||
// UUID不是离线格式 → 服务器在线模式的正版玩家
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] 结论: 正版玩家");
|
||||
"[Sdf1_login] 结论: 正版玩家(非离线UUID)");
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] ====== 验证结束 ======");
|
||||
callback.accept(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 离线UUID → 无法通过UUID区分正版/盗版,一律视为非正版
|
||||
// 正版玩家需通过 /mslogin OAuth验证 或 /login 手动登录
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] UUID为离线格式,"
|
||||
+ "查询Mojang API...");
|
||||
UUID mojangUuid = fetchMojangUuid(name);
|
||||
|
||||
if (mojangUuid != null) {
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] Mojang返回UUID: "
|
||||
+ mojangUuid);
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] 结论: 盗版客户端"
|
||||
+ "使用了正版名字");
|
||||
} else {
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] Mojang无此名字");
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] 结论: 纯离线玩家");
|
||||
}
|
||||
|
||||
"[Sdf1_login] 结论: 离线UUID玩家,需手动登录");
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] (正版玩家请使用 /mslogin 或 /正版 命令验证)");
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] ====== 验证结束 ======");
|
||||
callback.accept(false);
|
||||
@@ -104,55 +99,4 @@ public class VerificationManager {
|
||||
callback.accept(false);
|
||||
}
|
||||
}
|
||||
|
||||
private UUID fetchMojangUuid(String name) {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(
|
||||
"https://api.mojang.com/users/profiles/minecraft/"
|
||||
+ name);
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setConnectTimeout(5000);
|
||||
conn.setReadTimeout(5000);
|
||||
conn.setRequestMethod("GET");
|
||||
|
||||
int code = conn.getResponseCode();
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] Mojang API响应: " + code);
|
||||
|
||||
if (code == 200) {
|
||||
BufferedReader reader =
|
||||
new BufferedReader(
|
||||
new InputStreamReader(
|
||||
conn.getInputStream(),
|
||||
StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
reader.close();
|
||||
|
||||
String json = sb.toString();
|
||||
String uuidStr = json.replaceAll(
|
||||
".*\"id\"\\s*:\\s*\"([^\"]+)\".*",
|
||||
"$1");
|
||||
plugin.getLogger().info(
|
||||
"[Sdf1_login] Mojang原始UUID: "
|
||||
+ uuidStr);
|
||||
|
||||
String formatted = uuidStr.replaceFirst(
|
||||
"(\\w{8})(\\w{4})(\\w{4})(\\w{4})(\\w{12})",
|
||||
"$1-$2-$3-$4-$5");
|
||||
return UUID.fromString(formatted);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning(
|
||||
"[Sdf1_login] Mojang查询异常: "
|
||||
+ e.getMessage());
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"OP白名单": "服务器的好管家",
|
||||
"为什么要OP白名单": {
|
||||
"根因有几点": {
|
||||
"第一点": "已有OP乱给权限防不住",
|
||||
"第二点": "有人利用服务器漏洞拿到OP权限",
|
||||
"第三点": "不信任OP",
|
||||
"第四点": "想临时给某个玩家一个op,原生不支持。必须到期手动/deop user"
|
||||
}
|
||||
},
|
||||
"我们做了什么": {
|
||||
"第一层": "临时授权,允许你给OP设置到期时间,到期自动撤销",
|
||||
"第二层": "接管原生OP名单,不在白名单的一律撤销+报警"
|
||||
},
|
||||
"你需要做什么": [
|
||||
"共3步",
|
||||
"第一步:安装sdf1_login插件",
|
||||
"第二步改这个文件",
|
||||
"第三步按参考格式或后台指令添加授权",
|
||||
"第四步:玩家自动获得授权,整点自动生效(手动改本文件时,会在15、30、45、0秒的整点自动增量热重载)"
|
||||
],
|
||||
"----": "----",
|
||||
"启用状态": true,
|
||||
"无固定期限授权列表": [],
|
||||
"有固定期限授权列表": {},
|
||||
"非法持有报警信息": "§8[§cOP白名单§8] §f{user} §7不持有授权却持有 OP,已于 §f{time} §7自动撤销 §8| §8[OPWL] §f{user} §7held OP illegally, revoked at §f{time}",
|
||||
"默认时长": "24h",
|
||||
"时长支持格式": [
|
||||
"可以是中文口语:一个小时",
|
||||
"可以是英文one hour",
|
||||
"可以是标准写法:24H",
|
||||
"格式很宽松,只要是标准json字符串格式就行。至于你怎么写,插件会解析"
|
||||
],
|
||||
"报警模板变量": {
|
||||
"{user}": "非法持有的玩家名(也认 player / 用户 / 玩家 / username)",
|
||||
"{time}": "发现时间(也认 时间 / date / 日期)",
|
||||
"写法": "大小写不敏感,中英文变量名都支持;& 颜色码可用,渲染后自动转成§"
|
||||
},
|
||||
"接管说明": [
|
||||
"本插件【接管原生OP名单】:插件首次加载时,会把当前所有原生OP自动导入「无固定期限授权列表」并写回本文件。",
|
||||
"之后:任何持有OP但不在白名单的玩家(不在永久表/ 临时已过期 / 时长无法解析),一律自动撤销OP并按「非法持有报警信息」报警。",
|
||||
"所以以后新增OP请用 /opwl add <玩家>,别用原版 /op;否则会被15秒内的巡检撤掉。",
|
||||
"白名单全部写在本文件里(单文件),没有子目录也没有附属名单文件。"
|
||||
],
|
||||
"以下是模板文件": {
|
||||
"# 启用状态": true,
|
||||
"# 无固定期限授权列表": [
|
||||
"user1",
|
||||
"user2"
|
||||
],
|
||||
"# 有固定期限授权列表": {
|
||||
"user1": "2026-10-06 23:00",
|
||||
"user2": "1小时",
|
||||
"user3": "two hour"
|
||||
},
|
||||
"# 非法持有报警信息": "支持 {user} 与 {time} 变量,中英文写法都认;留空则用内置默认文案",
|
||||
"# 默认时长": "24小时"
|
||||
},
|
||||
"为什么这次选择json": {
|
||||
"根因依旧2点": {
|
||||
"1": "防止非专业人士修改本文件",
|
||||
"2": "使用专业格式,让小白试探着劝退(一旦解析失败,所有玩家OP全部卸载)"
|
||||
}
|
||||
},
|
||||
"指令参数": {
|
||||
"opwl": "帮助菜单",
|
||||
"opwl add user": "添加user用户为无固定期限用户",
|
||||
"opwl addtime user": "按默认时长添加临时授权用户",
|
||||
"opwl addtime user 1h": "给user用户价1小时授权",
|
||||
"opwl removetime user 1h": "给user用户扣1小时授权",
|
||||
"opwl remove user": "移除user的授权",
|
||||
"opwl list user": "获取user的授权状态",
|
||||
"opwl list": "列出OP白名单",
|
||||
"opwl reload": "重载OP白名单",
|
||||
"温馨提示": "opwl命令仅限控台执行,普通用户无权",
|
||||
"加授权完全无感": "因为用户本身没有op时,被授权后,插件立即给予OP并记录授权状态。删除也是。不需要先添加用户在添加授权。"
|
||||
},
|
||||
"安全升级": [
|
||||
"本文件即是配置文件也是说明书。这就意味着,你可以把其它混淆内容一起打包进本json文件,只要保留配置项。其它的你随便删改",
|
||||
"编辑本文件时,如果担心改坏了,直接联网找一个在线json解析器,把本文件扔上去。json的写法是双引号包裹内容,一对双引号就是一个键值对。用花括号做嵌套式。就像本文件的解释说明字段一样"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# 非法域名后缀列表
|
||||
# 每行一个后缀,支持以 * 开头表示匹配所有子域名
|
||||
# 修改此文件后无需重启,插件会在每次检测时重新加载
|
||||
# 默认包含常见非法注册域名后缀
|
||||
|
||||
com
|
||||
cn
|
||||
net
|
||||
org
|
||||
io
|
||||
xyz
|
||||
top
|
||||
club
|
||||
site
|
||||
online
|
||||
store
|
||||
tech
|
||||
info
|
||||
biz
|
||||
ru
|
||||
tk
|
||||
ml
|
||||
ga
|
||||
cf
|
||||
cc
|
||||
ws
|
||||
tv
|
||||
link
|
||||
click
|
||||
date
|
||||
vip
|
||||
party
|
||||
fun
|
||||
zone
|
||||
space
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Sdf1_login
|
||||
version: 1.0
|
||||
version: 3.0
|
||||
main: Sdf1_login.Main
|
||||
api-version: '1.21'
|
||||
commands:
|
||||
@@ -42,9 +42,9 @@ commands:
|
||||
签到:
|
||||
description: 每日签到(中文)
|
||||
usage: /签到
|
||||
oa:
|
||||
description: 申请删除账号
|
||||
usage: /oa [玩家名]
|
||||
#oa:
|
||||
# description: 申请删除账号
|
||||
# usage: /oa [玩家名]
|
||||
删除账号:
|
||||
description: 申请删除账号(中文)
|
||||
usage: /删除账号 [玩家名]
|
||||
@@ -60,18 +60,15 @@ commands:
|
||||
deny:
|
||||
description: 拒绝OA请求
|
||||
usage: /deny <编号> [理由]
|
||||
#sdf1debug:
|
||||
# description: 调试指令
|
||||
# usage: /sdf1debug <玩家> take.<阶段>
|
||||
email:
|
||||
description: 绑定邮箱
|
||||
usage: /email <邮箱>
|
||||
2fa:
|
||||
description: 二次验证(TOTP)绑定
|
||||
usage: /2fa add|code <6位>|remove <6位>
|
||||
绑定:
|
||||
description: 绑定邮箱(中文)
|
||||
usage: /绑定 <邮箱>
|
||||
调试:
|
||||
description: 调试模式
|
||||
usage: /调试
|
||||
绑定邮箱:
|
||||
description: 绑定邮箱
|
||||
usage: /绑定邮箱
|
||||
@@ -84,9 +81,9 @@ commands:
|
||||
垃圾清理:
|
||||
description: 手动清理掉落物
|
||||
usage: /垃圾清理
|
||||
menu:
|
||||
description: 雪球菜单开关控制
|
||||
usage: /menu <开/关>
|
||||
# menu:
|
||||
# description: 雪球菜单开关控制
|
||||
# usage: /menu <开/关>
|
||||
pvp :
|
||||
description: PVP榜单
|
||||
usage: /pvp
|
||||
@@ -95,4 +92,98 @@ commands:
|
||||
usage: /cypay
|
||||
printer :
|
||||
description: 打印债券流水
|
||||
usage: /printer
|
||||
usage: /printer
|
||||
shop :
|
||||
description: 游戏商人
|
||||
usage: /shop
|
||||
商店:
|
||||
description: 打开商店
|
||||
usage: /商店
|
||||
# testorder:
|
||||
# description: 测试订单中心
|
||||
# usage: /testorder
|
||||
protect:
|
||||
description: 区域保护
|
||||
usage: /protect
|
||||
landrec:
|
||||
description: 领地记录系统(回声碎片发放)
|
||||
usage: /landrec give [玩家]
|
||||
传送:
|
||||
description: 传送至领地
|
||||
usage: /传送 <领地名>
|
||||
update:
|
||||
description: 检查所有插件更新(集群控制)
|
||||
usage: /update
|
||||
web:
|
||||
description: 生成Web端登录令牌
|
||||
usage: /web
|
||||
控制台:
|
||||
description: 生成Web端登录令牌(中文版)
|
||||
usage: /控制台
|
||||
mslogin:
|
||||
description: 正版登录(Microsoft OAuth)
|
||||
usage: /mslogin
|
||||
正版:
|
||||
description: 正版登录(中文版)
|
||||
usage: /正版
|
||||
tpa:
|
||||
description: tp传送指令
|
||||
usage: /tpa
|
||||
tpaccept:
|
||||
description: 接受传送
|
||||
usage: /tpaccept
|
||||
tpdeny:
|
||||
description: 拒绝传送
|
||||
usage: /tpdeny
|
||||
tpauto:
|
||||
description: 自动接收传送
|
||||
usage: /tpauto
|
||||
tpacancel:
|
||||
description: 取消传送请求
|
||||
usage: /tpacancel
|
||||
tpaall:
|
||||
description: 请求传送所有人
|
||||
usage: /tpaall
|
||||
tpahere:
|
||||
description: 请求传送ta到自己身边
|
||||
usage: /tpahere
|
||||
sethome:
|
||||
description: 设置一个家
|
||||
usage: /sethome <名称>
|
||||
delhome:
|
||||
description: 删除一个家
|
||||
usage: /delhome <名称>
|
||||
home:
|
||||
description: 传送到家
|
||||
usage: /home [名称]
|
||||
homes:
|
||||
description: 查看所有家
|
||||
usage: /homes
|
||||
group:
|
||||
description: 用户组管理
|
||||
usage: /group <buy|renew|info> [组名]
|
||||
back:
|
||||
description: 快速返回最近记录点
|
||||
usage: /back
|
||||
|
||||
tempban:
|
||||
description: 临时封禁玩家
|
||||
usage: /tempban <玩家名/IP> <时长> [理由]
|
||||
tempbanip:
|
||||
description: 临时封禁IP或玩家
|
||||
usage: /tempbanip <IP/玩家名> <时长> [理由]
|
||||
banip:
|
||||
description: 永久封禁IP或玩家
|
||||
usage: /banip <IP/玩家名> [永久] [理由]
|
||||
whois:
|
||||
description: 查询玩家信息
|
||||
usage: /whois [玩家名]
|
||||
who:
|
||||
description: 查看在线玩家列表
|
||||
usage: /who
|
||||
清空队列:
|
||||
description: 清空DB队列中未回应的web同步任务(管理员)
|
||||
usage: /清空队列
|
||||
cleartake:
|
||||
description: 清空DB队列中未回应的web同步任务(管理员)
|
||||
usage: /cleartake
|
||||
|
||||
@@ -1,989 +0,0 @@
|
||||
Index: .gitattributes
|
||||
IDEA additional info:
|
||||
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
|
||||
<+>UTF-8
|
||||
===================================================================
|
||||
diff --git a/.gitattributes b/.gitattributes
|
||||
--- a/.gitattributes (revision 145b392112786b20534254a7a38d716657c962ab)
|
||||
+++ b/.gitattributes (revision 87de2e12c81f5a5e04825113e7835aca09e0fd09)
|
||||
@@ -1,6 +1,5 @@
|
||||
+*.md text=auto encoding=UTF-8
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
-*.gif binary
|
||||
-*.ico binary
|
||||
-*.bmp binary
|
||||
\ No newline at end of file
|
||||
+*.gif binary
|
||||
\ No newline at end of file
|
||||
Index: src/main/java/Sdf1_login/ConfigManager.java
|
||||
IDEA additional info:
|
||||
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
|
||||
<+>UTF-8
|
||||
===================================================================
|
||||
diff --git a/src/main/java/Sdf1_login/ConfigManager.java b/src/main/java/Sdf1_login/ConfigManager.java
|
||||
--- a/src/main/java/Sdf1_login/ConfigManager.java (revision 145b392112786b20534254a7a38d716657c962ab)
|
||||
+++ b/src/main/java/Sdf1_login/ConfigManager.java (revision 87de2e12c81f5a5e04825113e7835aca09e0fd09)
|
||||
@@ -312,6 +312,14 @@
|
||||
requesterPointsReward = parseInt(
|
||||
m.getOrDefault("报单人积分奖励", "10"),
|
||||
10);
|
||||
+ garbageEnabled = Boolean.parseBoolean(
|
||||
+ m.getOrDefault("垃圾站_启用", "true"));
|
||||
+ garbageInterval = parseInt(
|
||||
+ m.getOrDefault("垃圾站_清理间隔秒",
|
||||
+ "300"), 300);
|
||||
+ garbageMaxRounds = parseInt(
|
||||
+ m.getOrDefault("垃圾站_保留轮数",
|
||||
+ "1"), 1);
|
||||
|
||||
|
||||
}
|
||||
@@ -331,12 +339,10 @@
|
||||
L.add("挂机超时秒数=" + afkTimeout);
|
||||
// 垃圾站设置
|
||||
L.add("# ---- 垃圾站 ----");
|
||||
- L.add("垃圾站_启用: "
|
||||
- + garbageEnabled);
|
||||
- L.add("垃圾站_清理间隔秒: "
|
||||
- + garbageInterval);
|
||||
- L.add("垃圾站_保留轮数: "
|
||||
- + garbageMaxRounds);
|
||||
+ L.add("垃圾站_启用=" + garbageEnabled);
|
||||
+ L.add("垃圾站_清理间隔秒=" + garbageInterval);
|
||||
+ L.add("垃圾站_保留轮数=" + garbageMaxRounds);
|
||||
+
|
||||
|
||||
writeLines(file, L);
|
||||
}
|
||||
@@ -364,6 +370,11 @@
|
||||
L.add("基础经济奖励=" + baseEconomyReward);
|
||||
L.add("经济奖励每评分=" + providerEconomyPerScore);
|
||||
L.add("报单人积分奖励=" + requesterPointsReward);
|
||||
+ L.add("# ---- 垃圾站 ----");
|
||||
+ L.add("垃圾站_启用=true");
|
||||
+ L.add("垃圾站_清理间隔秒=300");
|
||||
+ L.add("垃圾站_保留轮数=1");
|
||||
+
|
||||
|
||||
writeLines(f, L);
|
||||
}
|
||||
Index: src/main/java/Sdf1_login/DatabaseManager.java
|
||||
IDEA additional info:
|
||||
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
|
||||
<+>UTF-8
|
||||
===================================================================
|
||||
diff --git a/src/main/java/Sdf1_login/DatabaseManager.java b/src/main/java/Sdf1_login/DatabaseManager.java
|
||||
--- a/src/main/java/Sdf1_login/DatabaseManager.java (revision 145b392112786b20534254a7a38d716657c962ab)
|
||||
+++ b/src/main/java/Sdf1_login/DatabaseManager.java (revision 87de2e12c81f5a5e04825113e7835aca09e0fd09)
|
||||
@@ -201,7 +201,18 @@
|
||||
+ "active INTEGER DEFAULT 1,"
|
||||
+ "join_time INTEGER DEFAULT 0"
|
||||
+ ")");
|
||||
-
|
||||
+ // 垃圾回收站物品表
|
||||
+ st.execute("CREATE TABLE IF NOT EXISTS "
|
||||
+ + "items ("
|
||||
+ + "id INTEGER PRIMARY KEY "
|
||||
+ + "AUTOINCREMENT,"
|
||||
+ + "player_name TEXT NOT NULL,"
|
||||
+ + "item_data TEXT DEFAULT '',"
|
||||
+ + "amount INTEGER DEFAULT 1,"
|
||||
+ + "create_time INTEGER DEFAULT 0"
|
||||
+ + ")");
|
||||
+ safeAdd(st, "menu_snowball",
|
||||
+ "INTEGER DEFAULT 1");
|
||||
st.close();
|
||||
logger.info("[Sdf1_login] 数据库初始化完成");
|
||||
} catch (Exception e) {
|
||||
Index: src/main/java/Sdf1_login/GUIManager.java
|
||||
IDEA additional info:
|
||||
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
|
||||
<+>UTF-8
|
||||
===================================================================
|
||||
diff --git a/src/main/java/Sdf1_login/GUIManager.java b/src/main/java/Sdf1_login/GUIManager.java
|
||||
--- a/src/main/java/Sdf1_login/GUIManager.java (revision 145b392112786b20534254a7a38d716657c962ab)
|
||||
+++ b/src/main/java/Sdf1_login/GUIManager.java (revision 87de2e12c81f5a5e04825113e7835aca09e0fd09)
|
||||
@@ -489,6 +489,12 @@
|
||||
"§e设置密码"));
|
||||
g.setItem(16, mkItem(Material.BARRIER,
|
||||
"§c删除用户", "§7二次验证删除"));
|
||||
+ // 雪球菜单检测(slot 14)
|
||||
+ g.setItem(14, mkItem(
|
||||
+ Material.SNOWBALL,
|
||||
+ "§e§l雪球菜单",
|
||||
+ "§7点击为该玩家发放菜单雪球"));
|
||||
+
|
||||
g.setItem(22, mkItem(Material.ARROW, "§7返回"));
|
||||
p.openInventory(g);
|
||||
}
|
||||
Index: src/main/java/Sdf1_login/GarbageManager.java
|
||||
IDEA additional info:
|
||||
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
|
||||
<+>UTF-8
|
||||
===================================================================
|
||||
diff --git a/src/main/java/Sdf1_login/GarbageManager.java b/src/main/java/Sdf1_login/GarbageManager.java
|
||||
--- a/src/main/java/Sdf1_login/GarbageManager.java (revision 145b392112786b20534254a7a38d716657c962ab)
|
||||
+++ b/src/main/java/Sdf1_login/GarbageManager.java (revision 87de2e12c81f5a5e04825113e7835aca09e0fd09)
|
||||
@@ -231,26 +231,26 @@
|
||||
|
||||
public void saveItem(ItemStack stack) {
|
||||
{
|
||||
- try {
|
||||
- String data =
|
||||
- stackToBase64(stack);
|
||||
- if (data.isEmpty()) return;
|
||||
- PreparedStatement ps = db.prepareStatement(
|
||||
- "INSERT INTO items "
|
||||
- + "(item_data, amount, "
|
||||
- + "round, create_time) "
|
||||
- + "VALUES (?,?,?,?)");
|
||||
- ps.setString(1, data);
|
||||
- ps.setInt(2, stack.getAmount());
|
||||
- ps.setInt(3, currentRound);
|
||||
- ps.setLong(4,
|
||||
- System.currentTimeMillis());
|
||||
- ps.executeUpdate();
|
||||
- ps.close();
|
||||
- } catch (Exception e) {
|
||||
- e.printStackTrace();
|
||||
- }
|
||||
- }}
|
||||
+ try {
|
||||
+ String data =
|
||||
+ stackToBase64(stack);
|
||||
+ if (data.isEmpty()) return;
|
||||
+ PreparedStatement ps = db.prepareStatement(
|
||||
+ "INSERT INTO items "
|
||||
+ + "(item_data, amount, "
|
||||
+ + "round, create_time) "
|
||||
+ + "VALUES (?,?,?,?)");
|
||||
+ ps.setString(1, data);
|
||||
+ ps.setInt(2, stack.getAmount());
|
||||
+ ps.setInt(3, currentRound);
|
||||
+ ps.setLong(4,
|
||||
+ System.currentTimeMillis());
|
||||
+ ps.executeUpdate();
|
||||
+ ps.close();
|
||||
+ } catch (Exception e) {
|
||||
+ e.printStackTrace();
|
||||
+ }
|
||||
+ }}
|
||||
|
||||
private int deleteOldItems(int beforeRound) {
|
||||
try {
|
||||
@@ -381,6 +381,38 @@
|
||||
.get("amount")).intValue();
|
||||
ItemStack stack = base64ToStack(data);
|
||||
if (stack != null) {
|
||||
+ try {
|
||||
+ byte[] bytes =
|
||||
+ stack.serializeAsBytes();
|
||||
+ String b64 = java.util.Base64
|
||||
+ .getEncoder()
|
||||
+ .encodeToString(bytes);
|
||||
+ int len = b64.length();
|
||||
+ String f = len > 15
|
||||
+ ? b64.substring(10, 15) : "短";
|
||||
+ String m = len >= 5
|
||||
+ ? b64.substring(len / 2,
|
||||
+ Math.min(len / 2 + 5, len))
|
||||
+ : "短";
|
||||
+ String b = len > 10
|
||||
+ ? b64.substring(len - 10, len - 5)
|
||||
+ : "短";
|
||||
+ plugin.getLogger().info(
|
||||
+ "[垃圾站加载] ID#" + id
|
||||
+ + " " + stack.getType()
|
||||
+ + " x" + amount
|
||||
+ + " base64[" + len + "]"
|
||||
+ + " 前=" + f
|
||||
+ + " 中=" + m
|
||||
+ + " 后=" + b);
|
||||
+ } catch (Exception ex) {
|
||||
+ plugin.getLogger().warning(
|
||||
+ "[垃圾站加载] 序列化异常: "
|
||||
+ + ex.getMessage());
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ if (stack != null) {
|
||||
stack.setAmount(amount);
|
||||
ItemMeta im =
|
||||
stack.getItemMeta();
|
||||
@@ -431,6 +463,10 @@
|
||||
g.setItem(51, mk(Material.ARROW,
|
||||
"§7已是最后一页"));
|
||||
}
|
||||
+ // 52格:返回按钮
|
||||
+ g.setItem(52, mk(Material.ARROW,
|
||||
+ "§7返回",
|
||||
+ "§7返回主菜单"));
|
||||
|
||||
p.openInventory(g);
|
||||
}
|
||||
@@ -459,6 +495,12 @@
|
||||
openRecyclePage(p, page + 1);
|
||||
return true;
|
||||
}
|
||||
+ // 52:返回
|
||||
+ if (slot == 52) {
|
||||
+ plugin.getGui().openMain(p);
|
||||
+ return true;
|
||||
+ }
|
||||
+
|
||||
// 50/52/53:不处理
|
||||
if (slot >= 49 && slot <= 53)
|
||||
return true;
|
||||
Index: src/main/java/Sdf1_login/GiftManager.java
|
||||
IDEA additional info:
|
||||
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
|
||||
<+>UTF-8
|
||||
===================================================================
|
||||
diff --git a/src/main/java/Sdf1_login/GiftManager.java b/src/main/java/Sdf1_login/GiftManager.java
|
||||
--- a/src/main/java/Sdf1_login/GiftManager.java (revision 145b392112786b20534254a7a38d716657c962ab)
|
||||
+++ b/src/main/java/Sdf1_login/GiftManager.java (revision 87de2e12c81f5a5e04825113e7835aca09e0fd09)
|
||||
@@ -155,55 +155,18 @@
|
||||
}
|
||||
|
||||
public boolean canClaim(Player p, int stage) {
|
||||
- Map<String, Object> user = plugin.getDb()
|
||||
- .getUser(p.getName());
|
||||
- if (user.isEmpty()) return false;
|
||||
- int curStage = ((Number) user.getOrDefault(
|
||||
- "gift_stage", 0)).intValue();
|
||||
- if (stage > curStage + 1) return false;
|
||||
- if (stage < 1 || stage > stages.size())
|
||||
- return false;
|
||||
- String claimed = (String) user.getOrDefault(
|
||||
- "gift_claimed", "");
|
||||
- if (claimed.contains("[" + stage + "]"))
|
||||
- return false;
|
||||
-
|
||||
- // debug 绕过:检查 tasks_completed
|
||||
- String completed = (String) user.getOrDefault(
|
||||
- "tasks_completed", "");
|
||||
- if (completed.contains("[" + stage + "]"))
|
||||
- return true;
|
||||
-
|
||||
- // 正常条件检查
|
||||
- long dbTime = ((Number) user.getOrDefault(
|
||||
- "total_online_time", 0L)).longValue();
|
||||
- long sessionTime = 0;
|
||||
- Object lastObj = plugin.getDb().getField(
|
||||
- p.getName(), "last_online_check");
|
||||
- if (lastObj != null && plugin.getLoggedIn()
|
||||
- .contains(p.getName())) {
|
||||
- long last = ((Number) lastObj).longValue();
|
||||
- if (last > 0) {
|
||||
- sessionTime =
|
||||
- System.currentTimeMillis() - last;
|
||||
- }
|
||||
- }
|
||||
- long onlineTime = dbTime + sessionTime;
|
||||
- int broken = ((Number) user.getOrDefault(
|
||||
- "blocks_broken", 0)).intValue();
|
||||
- int placed = ((Number) user.getOrDefault(
|
||||
- "blocks_placed", 0)).intValue();
|
||||
+ String name = p.getName();
|
||||
+ int curStage = ((Number) plugin.getDb()
|
||||
+ .getField(name, "gift_stage"))
|
||||
+ .intValue();
|
||||
+ String claimed = (String) plugin.getDb()
|
||||
+ .getField(name, "gift_claimed");
|
||||
+ if (claimed == null) claimed = "";
|
||||
+ return curStage >= stage
|
||||
+ && !claimed.contains(
|
||||
+ String.valueOf(stage));
|
||||
+ }
|
||||
|
||||
- switch (stage) {
|
||||
- case 1: return true;
|
||||
- case 2: return onlineTime >= 3600000;
|
||||
- case 3: return broken >= 100
|
||||
- && onlineTime >= 86400000L;
|
||||
- case 4: return (broken + placed) >= 300
|
||||
- && onlineTime >= 604800000L;
|
||||
- default: return false;
|
||||
- }
|
||||
- }
|
||||
|
||||
public void claimReward(Player p, int stage) {
|
||||
if (!canClaim(p, stage)) return;
|
||||
Index: src/main/java/Sdf1_login/Main.java
|
||||
IDEA additional info:
|
||||
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
|
||||
<+>UTF-8
|
||||
===================================================================
|
||||
diff --git a/src/main/java/Sdf1_login/Main.java b/src/main/java/Sdf1_login/Main.java
|
||||
--- a/src/main/java/Sdf1_login/Main.java (revision 145b392112786b20534254a7a38d716657c962ab)
|
||||
+++ b/src/main/java/Sdf1_login/Main.java (revision 87de2e12c81f5a5e04825113e7835aca09e0fd09)
|
||||
@@ -196,6 +196,8 @@
|
||||
getCommand("sdf1debug").setExecutor(this);
|
||||
if (getCommand("oa") != null)
|
||||
getCommand("oa").setExecutor(this);
|
||||
+ if (getCommand("menu") != null)
|
||||
+ getCommand("menu").setExecutor(this);
|
||||
|
||||
|
||||
getServer().getPluginManager()
|
||||
@@ -1099,6 +1101,9 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
+ if (isFrozen(p)) {
|
||||
+ p.setAllowFlight(true);
|
||||
+ }
|
||||
|
||||
if (!"manual".equals(config.approvalMode)
|
||||
&& !db.userExists(name)) {
|
||||
@@ -1238,36 +1243,82 @@
|
||||
private static final String MENU_SNOWBALL_TAG =
|
||||
"sdf1_menu";
|
||||
|
||||
+ // 雪球菜单
|
||||
public void giveMenuSnowball(Player p) {
|
||||
- // 检查背包里是否已有菜单雪球
|
||||
+ // 检查开关
|
||||
+ Object val = db.getField(
|
||||
+ p.getName(), "menu_snowball");
|
||||
+ if (val != null) {
|
||||
+ int on = val instanceof Number
|
||||
+ ? ((Number) val).intValue()
|
||||
+ : 1;
|
||||
+ if (on == 0) {
|
||||
+ return; // 开关关了,不发放
|
||||
+ }
|
||||
+ }
|
||||
+ // 检查是否已有
|
||||
for (ItemStack it : p.getInventory()
|
||||
.getContents()) {
|
||||
if (isMenuSnowball(it)) {
|
||||
- return; // 已有,不重复发
|
||||
+ return;
|
||||
}
|
||||
}
|
||||
- // 没有则发放
|
||||
+ // 发放
|
||||
ItemStack snow = new ItemStack(
|
||||
Material.SNOWBALL);
|
||||
ItemMeta im = snow.getItemMeta();
|
||||
if (im != null) {
|
||||
im.setDisplayName(
|
||||
"§e§l[菜单] §f右键打开主菜单");
|
||||
- List<String> lore = new ArrayList<>();
|
||||
+ List<String> lore =
|
||||
+ new ArrayList<>();
|
||||
lore.add("§7右键点击打开功能菜单");
|
||||
lore.add("§8" + MENU_SNOWBALL_TAG);
|
||||
im.setLore(lore);
|
||||
snow.setItemMeta(im);
|
||||
}
|
||||
- // 放到最后一个空格,避免覆盖
|
||||
- int emptySlot =
|
||||
- p.getInventory()
|
||||
- .firstEmpty();
|
||||
- if (emptySlot >= 0) {
|
||||
+ int slot = p.getInventory()
|
||||
+ .firstEmpty();
|
||||
+ if (slot >= 0) {
|
||||
+ p.getInventory().setItem(
|
||||
+ slot, snow);
|
||||
+ } else {
|
||||
+ p.getWorld().dropItemNaturally(
|
||||
+ p.getLocation(), snow);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /**
|
||||
+ * 手动发放雪球菜单(绕过开关检查)
|
||||
+ */
|
||||
+ public void forceGiveMenuSnowball(
|
||||
+ Player p) {
|
||||
+ // 只检查是否已有
|
||||
+ for (ItemStack it : p.getInventory()
|
||||
+ .getContents()) {
|
||||
+ if (isMenuSnowball(it)) {
|
||||
+ return;
|
||||
+ }
|
||||
+ }
|
||||
+ ItemStack snow = new ItemStack(
|
||||
+ Material.SNOWBALL);
|
||||
+ ItemMeta im = snow.getItemMeta();
|
||||
+ if (im != null) {
|
||||
+ im.setDisplayName(
|
||||
+ "§e§l[菜单] §f右键打开主菜单");
|
||||
+ List<String> lore =
|
||||
+ new ArrayList<>();
|
||||
+ lore.add("§7右键点击打开功能菜单");
|
||||
+ lore.add("§8" + MENU_SNOWBALL_TAG);
|
||||
+ im.setLore(lore);
|
||||
+ snow.setItemMeta(im);
|
||||
+ }
|
||||
+ int slot = p.getInventory()
|
||||
+ .firstEmpty();
|
||||
+ if (slot >= 0) {
|
||||
p.getInventory().setItem(
|
||||
- emptySlot, snow);
|
||||
+ slot, snow);
|
||||
} else {
|
||||
- // 背包满,丢在地上
|
||||
p.getWorld().dropItemNaturally(
|
||||
p.getLocation(), snow);
|
||||
}
|
||||
@@ -1296,21 +1347,24 @@
|
||||
@EventHandler
|
||||
public void onMove(PlayerMoveEvent e) {
|
||||
Player p = e.getPlayer();
|
||||
+ if (!isFrozen(p)) return;
|
||||
+
|
||||
Location from = e.getFrom();
|
||||
Location to = e.getTo();
|
||||
- if (isFrozen(p)) {
|
||||
- if (to != null && (from.getX() != to.getX()
|
||||
- || from.getY() != to.getY()
|
||||
- || from.getZ() != to.getZ())) {
|
||||
- e.setTo(e.getFrom());
|
||||
- }
|
||||
+ if (to == null) return;
|
||||
+
|
||||
+ // 允许转头(无位移)
|
||||
+ if (from.getBlockX() == to.getBlockX()
|
||||
+ && from.getBlockY() == to.getBlockY()
|
||||
+ && from.getBlockZ() == to.getBlockZ()) {
|
||||
return;
|
||||
}
|
||||
- if (to != null && (from.getX() != to.getX()
|
||||
- || from.getY() != to.getY()
|
||||
- || from.getZ() != to.getZ())) {
|
||||
- afk.recordAction(p.getUniqueId());
|
||||
- }
|
||||
+
|
||||
+ // 有位移 → 取消
|
||||
+ e.setCancelled(true);
|
||||
+
|
||||
+ // 防止"服务器未启用飞行"踢出
|
||||
+ p.setAllowFlight(true);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
@@ -1484,7 +1538,8 @@
|
||||
|
||||
|
||||
|
||||
- // ===== Inventory Click =====
|
||||
+ // ===== Inventory Click =====
|
||||
+ // ===== Inventory Click =====
|
||||
@EventHandler
|
||||
public void onInvClick(InventoryClickEvent e) {
|
||||
if (!(e.getWhoClicked() instanceof Player))
|
||||
@@ -1494,12 +1549,41 @@
|
||||
e.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
- String title =
|
||||
- e.getView().getTitle();
|
||||
+ String title = e.getView().getTitle();
|
||||
int slot = e.getRawSlot();
|
||||
|
||||
- if (needsPasswordChange.contains(p.getName())
|
||||
- && !title.equals("§6§l任务面板")) {
|
||||
+ // Shift+点击背包物品 → 存入垃圾箱
|
||||
+ if (e.isShiftClick()
|
||||
+ && e.getClickedInventory() != null
|
||||
+ && e.getClickedInventory()
|
||||
+ == p.getInventory()
|
||||
+ && e.getCurrentItem() != null
|
||||
+ && e.getCurrentItem().getType()
|
||||
+ != Material.AIR
|
||||
+ && !isMenuSnowball(
|
||||
+ e.getCurrentItem())
|
||||
+ && title.equals(
|
||||
+ "§6§l垃圾回收站")) {
|
||||
+ e.setCancelled(true);
|
||||
+ ItemStack clicked =
|
||||
+ e.getCurrentItem().clone();
|
||||
+ garbage.saveItem(clicked);
|
||||
+ e.getInventory().setItem(
|
||||
+ e.getSlot(), null);
|
||||
+ Bukkit.getScheduler()
|
||||
+ .runTaskLater(this,
|
||||
+ () -> {
|
||||
+ garbage.openRecyclePage(p,
|
||||
+ garbage.getRecyclePage(
|
||||
+ p.getName()));
|
||||
+ }, 1L);
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ if (needsPasswordChange
|
||||
+ .contains(p.getName())
|
||||
+ && !title.equals(
|
||||
+ "§6§l任务面板")) {
|
||||
e.setCancelled(true);
|
||||
p.sendMessage("§c请先修改密码!");
|
||||
return;
|
||||
@@ -1516,12 +1600,15 @@
|
||||
TicketManager.T_TICKET_ADMIN)
|
||||
|| title.equals(
|
||||
TicketManager.T_TICKET_DETAIL)
|
||||
- || title.equals("§d§l服务商面板")
|
||||
- || title.equals("§6§l抢单大厅")
|
||||
+ || title.equals(
|
||||
+ "§d§l服务商面板")
|
||||
+ || title.equals(
|
||||
+ "§6§l抢单大厅")
|
||||
|| title.equals(
|
||||
"§b§l我处理中的工单")) {
|
||||
e.setCancelled(true);
|
||||
- ticket.handleClick(p, title, slot);
|
||||
+ ticket.handleClick(
|
||||
+ p, title, slot);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1539,96 +1626,67 @@
|
||||
|
||||
ItemStack inSlot =
|
||||
e.getInventory().getItem(slot);
|
||||
- ItemStack onCursor = e.getCursor();
|
||||
+ ItemStack cursor = e.getCursor();
|
||||
|
||||
- // 1. 格子里是垃圾站物品 → 取出
|
||||
- if (isTrashDisplayItem(inSlot)) {
|
||||
- e.setCancelled(true);
|
||||
- try {
|
||||
- List<String> lore =
|
||||
- inSlot.getItemMeta()
|
||||
- .getLore();
|
||||
- String numStr =
|
||||
- lore.get(0)
|
||||
- .replace(
|
||||
- "§7ID: #", "")
|
||||
- .trim();
|
||||
- int dbId = Integer
|
||||
- .parseInt(numStr);
|
||||
- // 先从DB删除
|
||||
- if (garbage.removeItem(
|
||||
- dbId)) {
|
||||
- // 再给玩家(去掉ID lore)
|
||||
- ItemStack give =
|
||||
- inSlot.clone();
|
||||
- ItemMeta gm =
|
||||
- give.getItemMeta();
|
||||
- if (gm != null) {
|
||||
- List<String> newLore =
|
||||
- new ArrayList<>();
|
||||
- // 保留原始lore(跳过ID和数量行)
|
||||
- if (lore.size() > 2) {
|
||||
- for (int li = 2;
|
||||
- li < lore
|
||||
- .size();
|
||||
- li++) {
|
||||
- newLore.add(
|
||||
- lore.get(
|
||||
- li));
|
||||
- }
|
||||
- }
|
||||
- gm.setLore(
|
||||
- newLore.isEmpty()
|
||||
- ? null
|
||||
- : newLore);
|
||||
+ // ===== 取出 =====
|
||||
+ if (inSlot != null
|
||||
+ && inSlot.hasItemMeta()
|
||||
+ && inSlot.getItemMeta().hasLore()) {
|
||||
+ List<String> lore =
|
||||
+ inSlot.getItemMeta().getLore();
|
||||
+ if (lore != null && !lore.isEmpty()
|
||||
+ && lore.get(0)
|
||||
+ .startsWith("§7ID: #")) {
|
||||
+ e.setCancelled(true);
|
||||
+ try {
|
||||
+ String numStr = lore.get(0)
|
||||
+ .replace("§7ID: #", "")
|
||||
+ .trim();
|
||||
+ int dbId =
|
||||
+ Integer.parseInt(numStr);
|
||||
+ if (garbage.removeItem(dbId)) {
|
||||
+ ItemStack give =
|
||||
+ inSlot.clone();
|
||||
+ ItemMeta gm =
|
||||
+ give.getItemMeta();
|
||||
+ gm.setLore(null);
|
||||
give.setItemMeta(gm);
|
||||
- }
|
||||
- p.getInventory()
|
||||
- .addItem(give);
|
||||
- // 清空格子
|
||||
- e.getInventory()
|
||||
- .setItem(slot,
|
||||
- null);
|
||||
- }
|
||||
- } catch (Exception ex) {
|
||||
- p.sendMessage("§c取出失败");
|
||||
- }
|
||||
- return;
|
||||
+ p.getInventory()
|
||||
+ .addItem(give);
|
||||
+ e.getInventory()
|
||||
+ .setItem(slot, null);
|
||||
+ }
|
||||
+ } catch (Exception ex) {
|
||||
+ p.sendMessage("§c取出失败");
|
||||
+ }
|
||||
+ return;
|
||||
+ }
|
||||
}
|
||||
|
||||
- // 2. 格子空 + 手上有物品 → 放入
|
||||
- if ((inSlot == null
|
||||
- || inSlot.getType()
|
||||
- == Material.AIR)
|
||||
- && onCursor != null
|
||||
- && onCursor.getType()
|
||||
+ // ===== 光标存入 =====
|
||||
+ if (cursor != null
|
||||
+ && cursor.getType()
|
||||
!= Material.AIR
|
||||
- && !isMenuSnowball(
|
||||
- onCursor)) {
|
||||
- // 不取消!让物品自然进入格子
|
||||
- Bukkit.getScheduler()
|
||||
- .runTaskLater(this,
|
||||
- () -> {
|
||||
- ItemStack after =
|
||||
- e.getInventory()
|
||||
- .getItem(
|
||||
- slot);
|
||||
- if (after != null
|
||||
- && after.getType()
|
||||
- != Material.AIR) {
|
||||
- garbage.saveItem(
|
||||
- after
|
||||
- .clone());
|
||||
- e.getInventory()
|
||||
- .setItem(
|
||||
- slot,
|
||||
- null);
|
||||
- }
|
||||
- }, 1L);
|
||||
+ && !isMenuSnowball(cursor)) {
|
||||
+ ItemStack toSave = cursor.clone();
|
||||
+ getLogger().info(
|
||||
+ "[垃圾站] 存入: "
|
||||
+ + toSave.getType()
|
||||
+ + " x" + toSave.getAmount());
|
||||
+ garbage.saveItem(toSave);
|
||||
+ e.setCancelled(true);
|
||||
+ Bukkit.getScheduler().runTaskLater(
|
||||
+ this, () -> {
|
||||
+ p.getOpenInventory()
|
||||
+ .setCursor(null);
|
||||
+ garbage.openRecyclePage(p,
|
||||
+ garbage.getRecyclePage(
|
||||
+ p.getName()));
|
||||
+ }, 1L);
|
||||
return;
|
||||
}
|
||||
|
||||
- // 3. 其他情况
|
||||
+ // 默认取消
|
||||
e.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
@@ -1806,6 +1864,7 @@
|
||||
p.closeInventory();
|
||||
p.sendMessage("§e请输入邀请码:");
|
||||
}
|
||||
+
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1828,6 +1887,7 @@
|
||||
.replace("§e", "")
|
||||
.replace("§a", ""));
|
||||
}
|
||||
+
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1856,7 +1916,18 @@
|
||||
p.closeInventory();
|
||||
p.sendMessage("§e输入" + tgt
|
||||
+ "的临时密码:");
|
||||
- } else if (slot == 16) {
|
||||
+ } else if (slot == 14) {
|
||||
+ Player tp = Bukkit.getPlayer(tgt);
|
||||
+ if (tp != null && tp.isOnline()) {
|
||||
+ forceGiveMenuSnowball(tp);
|
||||
+ p.sendMessage("§a已发放雪球菜单给 "
|
||||
+ + tgt);
|
||||
+ } else {
|
||||
+ p.sendMessage("§c玩家不在线");
|
||||
+ }
|
||||
+
|
||||
+
|
||||
+ } else if (slot == 16) {
|
||||
chatInput.getState(p).type =
|
||||
ChatInputManager.InputType
|
||||
.ADMIN_DELETE_CONFIRM;
|
||||
@@ -1866,8 +1937,11 @@
|
||||
p.sendMessage(config.msg(
|
||||
"admin_delete_confirm"));
|
||||
}
|
||||
+
|
||||
return;
|
||||
+
|
||||
}
|
||||
+
|
||||
|
||||
// 积分商城
|
||||
if (title.startsWith("§d§l积分商城")) {
|
||||
@@ -1958,43 +2032,45 @@
|
||||
|
||||
// 回收站拖拽
|
||||
if (t.equals("§6§l垃圾回收站")) {
|
||||
- for (int rawSlot : e.getRawSlots()) {
|
||||
- if (rawSlot >= 49
|
||||
- && rawSlot <= 53) {
|
||||
+ for (int rawSlot :
|
||||
+ e.getRawSlots()) {
|
||||
+ if (rawSlot >= 49) {
|
||||
e.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
- // 0-48:不取消,延迟存DB
|
||||
+ // 0-48允许拖入,不取消
|
||||
+ // 延迟2tick后扫描格子
|
||||
+ final Player fp = p;
|
||||
Bukkit.getScheduler()
|
||||
- .runTaskLater(this, () -> {
|
||||
- for (int rawSlot :
|
||||
- e.getRawSlots()) {
|
||||
- if (rawSlot < 0
|
||||
- || rawSlot >= 49)
|
||||
- continue;
|
||||
- ItemStack item =
|
||||
- e.getInventory()
|
||||
- .getItem(
|
||||
- rawSlot);
|
||||
- if (item != null
|
||||
- && item.getType()
|
||||
- != Material.AIR
|
||||
- && !isMenuSnowball(
|
||||
- item)) {
|
||||
- garbage.saveItem(
|
||||
- item.clone());
|
||||
- e.getInventory()
|
||||
- .setItem(
|
||||
- rawSlot,
|
||||
- null);
|
||||
- }
|
||||
- }
|
||||
- }, 1L);
|
||||
+ .runTaskLater(this,
|
||||
+ () -> {
|
||||
+ Inventory top = fp
|
||||
+ .getOpenInventory()
|
||||
+ .getTopInventory();
|
||||
+ for (int rawSlot :
|
||||
+ e.getRawSlots()) {
|
||||
+ if (rawSlot < 0
|
||||
+ || rawSlot >= 49)
|
||||
+ continue;
|
||||
+ ItemStack item =
|
||||
+ top.getItem(rawSlot);
|
||||
+ if (item != null
|
||||
+ && item.getType()
|
||||
+ != Material.AIR
|
||||
+ && !isMenuSnowball(
|
||||
+ item)) {
|
||||
+ garbage.saveItem(
|
||||
+ item.clone());
|
||||
+ top.setItem(
|
||||
+ rawSlot, null);
|
||||
+ }
|
||||
+ }
|
||||
+ }, 2L);
|
||||
return;
|
||||
}
|
||||
|
||||
-
|
||||
+ //服务商工单系统
|
||||
if (t.equals(TicketManager.T_TICKET_MAIN)
|
||||
|| t.equals(
|
||||
TicketManager.T_TICKET_CREATE)
|
||||
@@ -2050,6 +2126,38 @@
|
||||
+ count + " 个掉落物");
|
||||
return true;
|
||||
}
|
||||
+ // /menu 开关雪球菜单
|
||||
+ if (cmdName.equals("menu")) {
|
||||
+ if (!(sender instanceof Player)) {
|
||||
+ sender.sendMessage(
|
||||
+ "§c仅玩家可用");
|
||||
+ return true;
|
||||
+ }
|
||||
+ Player p2 = (Player) sender;
|
||||
+ if (args.length == 0) {
|
||||
+ p2.sendMessage(
|
||||
+ "§e用法: /menu <开/关>");
|
||||
+ return true;
|
||||
+ }
|
||||
+ String arg = args[0];
|
||||
+ if (arg.equals("开")) {
|
||||
+ db.setField(p2.getName(),
|
||||
+ "menu_snowball", 1);
|
||||
+ p2.sendMessage(
|
||||
+ "§a雪球菜单已开启,"
|
||||
+ + "下次进入将发放");
|
||||
+ } else if (arg.equals("关")) {
|
||||
+ db.setField(p2.getName(),
|
||||
+ "menu_snowball", 0);
|
||||
+ p2.sendMessage(
|
||||
+ "§c雪球菜单已关闭,"
|
||||
+ + "下次进入不再发放");
|
||||
+ } else {
|
||||
+ p2.sendMessage(
|
||||
+ "§e用法: /menu <开/关>");
|
||||
+ }
|
||||
+ return true;
|
||||
+ }
|
||||
|
||||
if (cmdName.equals("reg")
|
||||
|| cmdName.equals("注册")) {
|
||||
@@ -2461,9 +2569,9 @@
|
||||
sender.sendMessage(
|
||||
"§e§l================================");
|
||||
}
|
||||
- }
|
||||
+ }
|
||||
|
||||
- // pw / password
|
||||
+ // pw / password
|
||||
if (sub.equals("pw")
|
||||
|| sub.equals("password")) {
|
||||
if (!(sender instanceof Player)) {
|
||||
@@ -2790,6 +2898,29 @@
|
||||
}
|
||||
return true;
|
||||
}
|
||||
+ // give - 手动发放雪球菜单
|
||||
+ if (sub.equals("give")) {
|
||||
+ if (args.length < 2) {
|
||||
+ sender.sendMessage(
|
||||
+ "§e用法: /sdf1_login give <玩家名>");
|
||||
+ return true;
|
||||
+ }
|
||||
+ Player tp = Bukkit.getPlayer(
|
||||
+ args[1]);
|
||||
+ if (tp == null || !tp.isOnline()) {
|
||||
+ sender.sendMessage(
|
||||
+ "§c玩家不在线: "
|
||||
+ + args[1]);
|
||||
+ return true;
|
||||
+ }
|
||||
+ forceGiveMenuSnowball(tp);
|
||||
+ tp.sendMessage(
|
||||
+ "§a管理员已为您发放菜单雪球");
|
||||
+ sender.sendMessage(
|
||||
+ "§a已发放雪球菜单给 "
|
||||
+ + args[1]);
|
||||
+ return true;
|
||||
+ }
|
||||
|
||||
// shopadd
|
||||
if (sub.equals("shopadd")) {
|
||||
@@ -3052,4 +3183,4 @@
|
||||
return list;
|
||||
}
|
||||
|
||||
-}
|
||||
+}
|
||||
\ No newline at end of file
|
||||
Index: src/main/java/Sdf1_login/TicketManager.java
|
||||
IDEA additional info:
|
||||
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
|
||||
<+>UTF-8
|
||||
===================================================================
|
||||
diff --git a/src/main/java/Sdf1_login/TicketManager.java b/src/main/java/Sdf1_login/TicketManager.java
|
||||
--- a/src/main/java/Sdf1_login/TicketManager.java (revision 145b392112786b20534254a7a38d716657c962ab)
|
||||
+++ b/src/main/java/Sdf1_login/TicketManager.java (revision 87de2e12c81f5a5e04825113e7835aca09e0fd09)
|
||||
@@ -1014,39 +1014,27 @@
|
||||
openDetail(p, id);
|
||||
return true;
|
||||
}
|
||||
- // 管理员:标记完结(自己在处理)
|
||||
- if (adm(p) && "processing".equals(st)
|
||||
+ // 管理员:标记完结(已回复状态)
|
||||
+ if (adm(p) && "replied".equals(st)
|
||||
&& asg.equals("admin")) {
|
||||
- Map<String, Object> tk =
|
||||
- plugin.getDb().getTicket(id);
|
||||
- String tkType = sv(tk, "type");
|
||||
- if (TYPE_DELETE.equals(tkType)) {
|
||||
- approveDeleteTicket(p, id);
|
||||
- return true;
|
||||
- }
|
||||
- plugin.getDb().completeTicket(id);
|
||||
- p.sendMessage("§a工单 #" + id
|
||||
- + " 已标记完结");
|
||||
- openDetail(p, id);
|
||||
- return true;
|
||||
- }
|
||||
- // 管理员:标记完结(已回复状态)
|
||||
- if (adm(p) && "replied".equals(st)
|
||||
- && asg.equals("admin")) {
|
||||
- Map<String, Object> tk =
|
||||
- plugin.getDb().getTicket(id);
|
||||
- String tkType = sv(tk, "type");
|
||||
- if (TYPE_DELETE.equals(tkType)) {
|
||||
- approveDeleteTicket(p, id);
|
||||
- return true;
|
||||
- }
|
||||
- plugin.getDb().completeTicket(id);
|
||||
- p.sendMessage("§a工单 #" + id
|
||||
- + " 已标记完结");
|
||||
- openDetail(p, id);
|
||||
- return true;
|
||||
+
|
||||
+ // 管理员:标记完结(已回复状态)
|
||||
+ if (adm(p) && "replied".equals(st)
|
||||
+ && asg.equals("admin")) {
|
||||
+ Map<String, Object> tk =
|
||||
+ plugin.getDb().getTicket(id);
|
||||
+ String tkType = sv(tk, "type");
|
||||
+ if (TYPE_DELETE.equals(tkType)) {
|
||||
+ approveDeleteTicket(p, id);
|
||||
+ return true;
|
||||
+ }
|
||||
+ plugin.getDb().completeTicket(id);
|
||||
+ p.sendMessage("§a工单 #" + id
|
||||
+ + " 已标记完结");
|
||||
+ openDetail(p, id);
|
||||
+ return true;
|
||||
+ }
|
||||
}
|
||||
-
|
||||
// 服务商:抢单
|
||||
if (plugin.getDb()
|
||||
.isServiceProvider(p.getName())
|
||||
Index: src/main/resources/plugin.yml
|
||||
IDEA additional info:
|
||||
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
|
||||
<+>UTF-8
|
||||
===================================================================
|
||||
diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml
|
||||
--- a/src/main/resources/plugin.yml (revision 145b392112786b20534254a7a38d716657c962ab)
|
||||
+++ b/src/main/resources/plugin.yml (revision 87de2e12c81f5a5e04825113e7835aca09e0fd09)
|
||||
@@ -84,3 +84,6 @@
|
||||
垃圾清理:
|
||||
description: 手动清理掉落物
|
||||
usage: /垃圾清理
|
||||
+ menu:
|
||||
+ description: 雪球菜单开关控制
|
||||
+ usage: /menu <开/关>
|
||||
\ No newline at end of file
|
||||