Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61fdd106b2 | ||
|
|
22e4d262dc | ||
|
|
a5cb5afba7 | ||
|
|
855e45cb7f | ||
|
|
ac64aee365 | ||
|
|
14db0aa10c | ||
|
|
d0821b1ebe | ||
|
|
77f8289c64 | ||
|
|
2a1ce02e71 | ||
|
|
04678b29b0 | ||
|
|
f084304cda | ||
|
|
d9fe2818ab | ||
|
|
8311c412a1 | ||
|
|
311138b3a6 | ||
|
|
55d24c2d99 | ||
|
|
3cd1999b85 | ||
|
|
c7d89a972d | ||
|
|
afcce6b3e0 | ||
|
|
0fdeb88559 | ||
|
|
67f4438f0d | ||
|
|
0d4e1e6f33 | ||
|
|
644a8c4744 | ||
|
|
e67015f7a3 | ||
|
|
4cff8236b6 | ||
|
|
7b648d8729 | ||
|
|
0c9543d63e | ||
|
|
9c05d81047 | ||
|
|
7eb5bdc093 | ||
|
|
1a5487a25e | ||
|
|
8abdb368a7 | ||
|
|
1b02f16816 | ||
|
|
d3d6c7b67c | ||
|
|
24ecdf1ac9 | ||
|
|
a91b51f26d | ||
|
|
46ee04a880 | ||
|
|
aca8c31cc7 | ||
|
|
3635a0c3ae | ||
|
|
806e2f6ed0 | ||
|
|
5816391b56 | ||
|
|
b22c1342b1 | ||
|
|
e40614282d | ||
|
|
ca7d35b252 | ||
|
|
7dd5fef5f9 | ||
|
|
c947ef57ab | ||
|
|
4d4fee6f2e | ||
|
|
49190f0fc1 | ||
|
|
f58e73ca9e | ||
|
|
fef3f51203 | ||
|
|
53af7bc23b | ||
|
|
9f155eecb0 | ||
|
|
8d5fa288a4 | ||
|
|
08b2233fc3 | ||
|
|
699d455bab | ||
|
|
6c233939de | ||
|
|
12e68060fa | ||
|
|
2ee874eeda | ||
|
|
d7d5ce7039 | ||
|
|
cbc3cb4db7 | ||
|
|
6f831f065b | ||
|
|
3ecadd3228 | ||
|
|
d11987f97b | ||
|
|
a175024766 | ||
|
|
23c1b9e468 | ||
|
|
6d19e23f42 | ||
|
|
ae69036b9b | ||
|
|
9bc8aaac37 | ||
|
|
c83eaad23f | ||
|
|
c52d235972 | ||
|
|
9a6a983ad7 | ||
|
|
8d73722b6a | ||
|
|
1c34c9618b | ||
|
|
2d9dd5b438 | ||
|
|
9cd85013d5 | ||
|
|
39d475a33b | ||
|
|
17c1b0c44f | ||
|
|
26ab7b2435 | ||
|
|
cfe2eafc64 | ||
|
|
4352a35124 | ||
|
|
4eec0c9a39 | ||
|
|
21c61e9e5c | ||
|
|
df56e2c293 | ||
|
|
c214abeafa | ||
|
|
5e9282926c | ||
|
|
da89a98ef2 | ||
|
|
8a167d4b64 | ||
|
|
4a24e20bd6 | ||
|
|
ba63671cc3 | ||
|
|
4ba2947ef2 | ||
|
|
c95a0eee60 | ||
|
|
572bd2fce2 | ||
|
|
b0e1c5cf37 | ||
|
|
0bba2700c0 | ||
|
|
a1e59363d1 | ||
|
|
0837386623 | ||
|
|
f6ad2ca3f9 | ||
|
|
54d520ab3d | ||
|
|
f844d5c618 | ||
|
|
baed9c8e43 | ||
|
|
9f3dffee1e | ||
|
|
b2aa317c75 | ||
|
|
136cf9b8f8 | ||
|
|
d9f7f2de71 | ||
|
|
84e6d3bbc0 | ||
|
|
34a344f39c | ||
|
|
84d351b116 | ||
|
|
ad11a2bcd3 | ||
|
|
dea0370084 | ||
|
|
d6af2f69e0 | ||
|
|
70f6112ad8 |
+3
-1
@@ -119,5 +119,7 @@ test.*
|
||||
*.py
|
||||
# 屏蔽其它文件
|
||||
*.md
|
||||
# 过滤readme
|
||||
# 过滤readme和配置文件
|
||||
!readme.md
|
||||
!scr/main/resources
|
||||
!src/main/resources/**/*.*
|
||||
|
||||
@@ -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,4 +1,13 @@
|
||||
# Sdf1系列登录插件使用说明
|
||||
# Sdf1系列多功能综合插件使用说明
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目(Sdf1_login 插件,含 Java 端与 php 端)采用 **GNU Affero General Public License v3.0(AGPL-3.0)** 授权,协议全文见 [LICENSE](LICENSE)。
|
||||
|
||||
- 你可以自由使用、修改、分发本项目。
|
||||
- **若你把本项目(或其修改版)部署为网络服务对外开放,必须向使用该服务的用户提供完整的对应源代码**(AGPL 第 13 条)。
|
||||
- 衍生作品必须同样以 AGPL-3.0 授权分发。
|
||||
- 本项目按「原样」提供,不附带任何担保。
|
||||
|
||||
# 特别说明:本插件仅有简中语言版本,如需除简中以外的任何版本,请下载插件源码,自行修改!
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<java.version>27</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
|
||||
@@ -0,0 +1,698 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
import io.papermc.paper.scoreboard.numbers.NumberFormat;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.event.HoverEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextColor;
|
||||
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerChangedWorldEvent;
|
||||
import org.bukkit.scoreboard.Criteria;
|
||||
import org.bukkit.scoreboard.DisplaySlot;
|
||||
import org.bukkit.scoreboard.Objective;
|
||||
import org.bukkit.scoreboard.Score;
|
||||
import org.bukkit.scoreboard.Scoreboard;
|
||||
import org.bukkit.scoreboard.ScoreboardManager;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.io.StringReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* ★ 第35批:公告计分板(侧边栏)。
|
||||
*
|
||||
* <p><b>数据源</b>:{@code plugins/Sdf1_login/公告/公告.md}
|
||||
* 头部 {@code ## ----公告区域开始----} ~ {@code ## ----公告区域结束----}
|
||||
* 之间的 ```json 代码块。块的键是<b>排序位</b>,值是公告条目。
|
||||
*
|
||||
* <p><b>规则</b>:
|
||||
* <ul>
|
||||
* <li>键 = 排序位(升序,越小越靠上);键冲突 → <b>自动进位</b>
|
||||
* (往后找第一个空位)。用 JsonReader 流式解析,
|
||||
* 重复键不会被 Gson 的 Map 覆盖掉</li>
|
||||
* <li>msg 为空 → 整条跳过(空白键值自动跳过)</li>
|
||||
* <li>msg 超过 15 个字符 → 截断换行(每行最多 15 字,超出的另起一行)</li>
|
||||
* <li>msg 是 http/https 链接 → 整条公告可点击(打开链接,悬停显示完整地址)</li>
|
||||
* <li>color 支持 16 色英文名 / #RRGGBB / r=0 g=0 b=0;
|
||||
* 未填或非法 → 回退 MC 原版白色</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>渲染口径(paper-api 26.2 + 服务端 jar 字节码实测)</b>:
|
||||
* <ul>
|
||||
* <li>原版侧边栏<b>不解析 entry 里的 § 颜色码</b>(entry 只是纯文本标识),
|
||||
* 所以颜色必须走 {@code Score.customName(Component)} 下发;
|
||||
* entry 改用纯 ASCII 唯一串 {@code n0/n1/...},天然规避同名塌缩。</li>
|
||||
* <li>侧边栏默认 <b>descending(高分在上)</b> → 分数<b>递减</b>赋值,
|
||||
* 排序位小的排最上面。</li>
|
||||
* <li>侧边栏<b>最多只显示 15 行</b> → 超出部分丢弃并告警。</li>
|
||||
* <li>数字格式设 {@code NumberFormat.blank()} → 右侧不露分数。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>与 PVP 计分板共存</b>:PVP 区域 / 竞技场内由 PVP 模块接管计分板,
|
||||
* 本类只在玩家空闲(不在 PVP 区域、不在竞技场)时把公告板挂上去。
|
||||
*/
|
||||
public class AnnouncementBoard implements Listener {
|
||||
|
||||
private final Main plugin;
|
||||
|
||||
/** 公告配置文件:plugins/Sdf1_login/公告/公告.md */
|
||||
private File mdFile;
|
||||
|
||||
/** 上次解析时的文件修改时间(用于热重载检测) */
|
||||
private long lastModified = 0L;
|
||||
|
||||
/** 公告区域标记 */
|
||||
private static final String ZONE_START = "## ----公告区域开始----";
|
||||
private static final String ZONE_END = "## ----公告区域结束----";
|
||||
|
||||
/** 计分板目标名(objective 名) */
|
||||
private static final String OBJECTIVE = "sdf1_notice";
|
||||
|
||||
/** 单行最大字符数(超出截断换行) */
|
||||
static final int MAX_LINE_CHARS = 15;
|
||||
|
||||
/** 原版侧边栏最多显示的行数(硬限制) */
|
||||
static final int MAX_SIDEBAR_ROWS = 15;
|
||||
|
||||
/** 公告板标题 */
|
||||
private static final String TITLE = "§6§l服务器公告";
|
||||
|
||||
/** 16 色英文名(与 JsonMessageGui 口径一致) */
|
||||
private static final Set<String> NATIVE_COLORS = new LinkedHashSet<>(Arrays.asList(
|
||||
"black", "dark_blue", "dark_green", "dark_aqua",
|
||||
"dark_red", "dark_purple", "gold", "gray",
|
||||
"dark_gray", "blue", "green", "aqua",
|
||||
"red", "light_purple", "yellow", "white"));
|
||||
|
||||
/** 链接识别(整条 msg 是链接才算) */
|
||||
private static final Pattern URL_PATTERN =
|
||||
Pattern.compile("^(https?://\\S+)$", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
/** ★ 第36批:公告板总开关(公告.md 顶层键 开关状态:true/1=开,false/0=关) */
|
||||
private boolean enabled = true;
|
||||
|
||||
/** ★ 第36批:翻页秒数(公告.md 顶层键 翻页时间:纯数字=秒) */
|
||||
private int pageSeconds = 15;
|
||||
|
||||
/** ★ 第36批:当前页码(0 起,全局统一页切) */
|
||||
private int currentPage = 0;
|
||||
|
||||
/** ★ 第36批:翻页定时任务 */
|
||||
private BukkitTask pagerTask = null;
|
||||
|
||||
/** ★ 第36批:翻页任务当前周期(秒),与配置不一致才重建 */
|
||||
private int pagerSeconds = -1;
|
||||
|
||||
/** 解析出的公告条目(排序位 → 条目),按排序位升序 */
|
||||
private final Map<Integer, Notice> notices = new LinkedHashMap<>();
|
||||
|
||||
/** 单条公告 */
|
||||
public static final class Notice {
|
||||
/** 排序位 */
|
||||
public final int slot;
|
||||
/** 原始文本 */
|
||||
public final String msg;
|
||||
/** 归一化后的颜色(合法色 / null=白色) */
|
||||
public final TextColor color;
|
||||
/** 链接地址(非链接时 null) */
|
||||
public final String url;
|
||||
|
||||
Notice(int slot, String msg, TextColor color, String url) {
|
||||
this.slot = slot;
|
||||
this.msg = msg;
|
||||
this.color = color;
|
||||
this.url = url;
|
||||
}
|
||||
}
|
||||
|
||||
/** ★ 第36批:渲染用的一行(公告 + 该行文本),用于按页切片 */
|
||||
private static final class Row {
|
||||
final Notice n;
|
||||
final String line;
|
||||
|
||||
Row(Notice n, String line) {
|
||||
this.n = n;
|
||||
this.line = line;
|
||||
}
|
||||
}
|
||||
|
||||
public AnnouncementBoard(Main plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
/** 初始化:释放模板 → 解析 → 首次渲染 → 启动热重载监听 */
|
||||
public void init() {
|
||||
try {
|
||||
File dir = new File(plugin.getDataFolder(), "公告");
|
||||
if (!dir.exists()) dir.mkdirs();
|
||||
mdFile = new File(dir, "公告.md");
|
||||
if (!mdFile.exists()) {
|
||||
try (InputStream in = plugin.getResource("公告/公告.md")) {
|
||||
if (in != null) {
|
||||
Files.copy(in, mdFile.toPath());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[公告板] 模板释放失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[公告板] 初始化失败: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
reload();
|
||||
applyAll();
|
||||
startAutoReload();
|
||||
}
|
||||
|
||||
/** 启动文件热重载:每 30 秒比对一次修改时间 */
|
||||
private void startAutoReload() {
|
||||
Bukkit.getScheduler().runTaskTimer(plugin, () -> {
|
||||
if (mdFile == null || !mdFile.exists()) return;
|
||||
long mt = mdFile.lastModified();
|
||||
if (mt == lastModified) return;
|
||||
lastModified = mt;
|
||||
reloadAndApply();
|
||||
plugin.getLogger().info("[公告板] 检测到公告文件变更,已自动重载");
|
||||
}, 600L, 600L);
|
||||
}
|
||||
|
||||
/** 重新解析公告.md */
|
||||
public void reload() {
|
||||
// ★ 第36批:配置默认值每次重载重置(md 里没写对应键就回默认)
|
||||
enabled = true;
|
||||
pageSeconds = 15;
|
||||
currentPage = 0;
|
||||
List<Notice> list = parse();
|
||||
notices.clear();
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
Notice n = list.get(i);
|
||||
notices.put(n.slot, n);
|
||||
}
|
||||
if (mdFile != null && mdFile.exists()) {
|
||||
lastModified = mdFile.lastModified();
|
||||
}
|
||||
int rows = totalRows();
|
||||
int pages = (rows + MAX_SIDEBAR_ROWS - 1) / MAX_SIDEBAR_ROWS;
|
||||
plugin.getLogger().info("[公告板] 已加载 " + notices.size()
|
||||
+ " 条公告,开关=" + (enabled ? "开" : "关")
|
||||
+ ",翻页=" + pageSeconds + "秒,共 " + rows
|
||||
+ " 行 / " + pages + " 页");
|
||||
// ★ 第36批:按当前翻页秒数启动/重建翻页任务
|
||||
restartPager();
|
||||
}
|
||||
|
||||
/** 重载并立即刷新所有玩家 */
|
||||
public void reloadAndApply() {
|
||||
reload();
|
||||
applyAll();
|
||||
}
|
||||
|
||||
public File getMdFile() {
|
||||
return mdFile;
|
||||
}
|
||||
|
||||
public int getNoticeCount() {
|
||||
return notices.size();
|
||||
}
|
||||
|
||||
// ==================== 解析 ====================
|
||||
|
||||
/**
|
||||
* 解析公告.md 里的 JSON 块。
|
||||
*
|
||||
* <p>用 {@link JsonReader} 流式解析而非 {@code JsonObject}:
|
||||
* 后者会把重复键覆盖掉,「键冲突自动进位」就无从谈起。
|
||||
*
|
||||
* @return 按排序位升序的公告列表
|
||||
*/
|
||||
private List<Notice> parse() {
|
||||
List<Notice> out = new ArrayList<>();
|
||||
if (mdFile == null || !mdFile.exists()) return out;
|
||||
String text;
|
||||
try {
|
||||
text = new String(Files.readAllBytes(mdFile.toPath()), StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[公告板] 读取失败: " + e.getMessage());
|
||||
return out;
|
||||
}
|
||||
|
||||
// ★ 变量名避开 e/ex(否则遮蔽 catch 块里的 Exception e)
|
||||
int zStart = text.indexOf(ZONE_START);
|
||||
int zEnd = text.indexOf(ZONE_END);
|
||||
if (zStart < 0 || zEnd < 0 || zEnd <= zStart) {
|
||||
plugin.getLogger().warning("[公告板] 未找到公告区域标记("
|
||||
+ ZONE_START + " ~ " + ZONE_END + "),公告板为空");
|
||||
return out;
|
||||
}
|
||||
String zone = text.substring(zStart + ZONE_START.length(), zEnd);
|
||||
|
||||
String json = extractJsonBlock(zone);
|
||||
// ★ 第38批:剥掉尾逗号(实测 Gson 连 lenient 都不认尾逗号,用户手抖多打
|
||||
// 一个逗号就会让整块 json 解析失败、公告板一条都加载不出来)
|
||||
if (json != null) json = ConfigManager.stripTrailingCommas(json);
|
||||
if (json == null || json.trim().isEmpty()) {
|
||||
plugin.getLogger().warning("[公告板] 公告区域内没有 json 代码块");
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- 流式解析:保留重复键 ----
|
||||
List<Notice> raw = new ArrayList<>();
|
||||
int autoSlot = 1000000; // 非数字键的兜底排序位(排在数字键之后)
|
||||
JsonReader reader = null;
|
||||
try {
|
||||
reader = new JsonReader(new StringReader(json));
|
||||
reader.setLenient(true);
|
||||
reader.beginObject();
|
||||
while (reader.hasNext()) {
|
||||
String key = reader.nextName();
|
||||
// ★ 第36批:顶层配置键(开关状态/翻页时间)值是标量,
|
||||
// 必须先识别 —— 否则对 true / 15 调 beginObject() 会抛
|
||||
// IllegalStateException,整份解析失败、公告板全空。
|
||||
if (isConfigKey(key)) {
|
||||
readConfig(key, reader);
|
||||
continue;
|
||||
}
|
||||
if (reader.peek() != JsonToken.BEGIN_OBJECT) {
|
||||
// 非对象值(标量/数组/NULL)→ 不是公告条目,跳过
|
||||
reader.skipValue();
|
||||
continue;
|
||||
}
|
||||
String msg = null;
|
||||
String colorRaw = null;
|
||||
reader.beginObject();
|
||||
while (reader.hasNext()) {
|
||||
String vk = reader.nextName();
|
||||
// ★ 第36批:键名兼容中文(默认 msg/color,兼容 消息/颜色)
|
||||
if ("msg".equals(vk) || "消息".equals(vk)) {
|
||||
if (reader.peek() == JsonToken.NULL) reader.skipValue();
|
||||
else msg = reader.nextString();
|
||||
} else if ("color".equals(vk) || "颜色".equals(vk)) {
|
||||
if (reader.peek() == JsonToken.NULL) reader.skipValue();
|
||||
else colorRaw = reader.nextString();
|
||||
} else {
|
||||
reader.skipValue();
|
||||
}
|
||||
}
|
||||
reader.endObject();
|
||||
|
||||
if (msg == null) continue;
|
||||
msg = msg.trim();
|
||||
if (msg.isEmpty()) continue; // 空白键值自动跳过
|
||||
if (colorRaw == null) colorRaw = "";
|
||||
else colorRaw = colorRaw.trim();
|
||||
|
||||
int slot;
|
||||
try {
|
||||
slot = Integer.parseInt(key.trim());
|
||||
} catch (Exception ex) {
|
||||
slot = autoSlot++;
|
||||
}
|
||||
|
||||
TextColor color = parseColor(colorRaw);
|
||||
String url = null;
|
||||
Matcher m = URL_PATTERN.matcher(msg);
|
||||
if (m.matches()) url = m.group(1);
|
||||
|
||||
raw.add(new Notice(slot, msg, color, url));
|
||||
}
|
||||
reader.endObject();
|
||||
} catch (Exception ex) {
|
||||
plugin.getLogger().warning("[公告板] json 解析失败: " + ex.getMessage());
|
||||
return out;
|
||||
} finally {
|
||||
if (reader != null) {
|
||||
try {
|
||||
reader.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// ---- 排序(手动选择排序,不用 Comparator/lambda)----
|
||||
sortBySlot(raw);
|
||||
|
||||
// ---- 排序位冲突 → 自动进位(往后找第一个空位)----
|
||||
Set<Integer> used = new LinkedHashSet<>();
|
||||
// ★ 第36批:color 继承(用户明令)—— 未填/非法 → 继承最近一条
|
||||
// 合法 color;一条都没找到 → null(渲染回退 MC 原版白色)
|
||||
TextColor inherit = null;
|
||||
for (int i = 0; i < raw.size(); i++) {
|
||||
Notice n = raw.get(i);
|
||||
int s2 = n.slot;
|
||||
if (s2 < 0) s2 = 0;
|
||||
while (used.contains(s2)) s2++;
|
||||
used.add(s2);
|
||||
TextColor c = n.color;
|
||||
if (c == null) c = inherit;
|
||||
else inherit = c;
|
||||
out.add(new Notice(s2, n.msg, c, n.url));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ==================== 第36批:配置 / 翻页 ====================
|
||||
|
||||
/** ★ 第36批:顶层配置键识别(公告板开关 / 翻页秒数,中文键) */
|
||||
private static boolean isConfigKey(String key) {
|
||||
if (key == null) return false;
|
||||
String k = key.trim();
|
||||
return "开关状态".equals(k) || "开关".equals(k)
|
||||
|| "翻页时间".equals(k) || "翻页".equals(k);
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第36批:读标量配置值。
|
||||
* JSON 的 true/false(BOOLEAN)、15(NUMBER)、"15"(STRING)
|
||||
* 统一转成 String,交给 parseOnOff / parseInt 处理。
|
||||
*/
|
||||
private static String readScalar(JsonReader r) throws java.io.IOException {
|
||||
JsonToken t = r.peek();
|
||||
if (t == JsonToken.NULL) {
|
||||
r.nextNull();
|
||||
return null;
|
||||
}
|
||||
if (t == JsonToken.BOOLEAN) return String.valueOf(r.nextBoolean());
|
||||
if (t == JsonToken.NUMBER || t == JsonToken.STRING) {
|
||||
return r.nextString();
|
||||
}
|
||||
r.skipValue();
|
||||
return null;
|
||||
}
|
||||
|
||||
/** ★ 第36批:读取并应用一条顶层配置 */
|
||||
private void readConfig(String key, JsonReader reader) {
|
||||
String k = key.trim();
|
||||
String v;
|
||||
try {
|
||||
v = readScalar(reader);
|
||||
} catch (Exception ex) {
|
||||
try { reader.skipValue(); } catch (Exception ignored) { }
|
||||
return;
|
||||
}
|
||||
if ("开关状态".equals(k) || "开关".equals(k)) {
|
||||
enabled = parseOnOff(v);
|
||||
} else {
|
||||
// 翻页时间:纯数字=秒(非法/<=0 → 保留默认15)
|
||||
int sec = -1;
|
||||
if (v != null) {
|
||||
try { sec = Integer.parseInt(v.trim()); }
|
||||
catch (Exception ignored) { }
|
||||
}
|
||||
if (sec > 0) pageSeconds = sec;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* ★ 第36批:开关值解析(用户明令)——
|
||||
* true / 1 → 开;false / 0 → 关;未识别 → 开(配置写错不至于把板关死)。
|
||||
*/
|
||||
static boolean parseOnOff(String v) {
|
||||
if (v == null) return true;
|
||||
String t = v.trim().toLowerCase(Locale.ROOT);
|
||||
if (t.equals("0") || t.equals("false") || t.equals("off")
|
||||
|| t.equals("no") || t.equals("关") || t.equals("否")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** ★ 第36批:当前公告拆行后的总行数 */
|
||||
private int totalRows() {
|
||||
int rows = 0;
|
||||
for (Notice n : notices.values()) {
|
||||
rows += splitLine(n.msg).size();
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
/**
|
||||
* ★ 第36批:翻页任务 —— 总行数超过侧边栏 15 行时,
|
||||
* 每「翻页时间」秒切下一页并刷新全员;只有一页时不动作。
|
||||
*/
|
||||
private void restartPager() {
|
||||
int sec = Math.max(1, pageSeconds);
|
||||
if (pagerTask != null && sec == pagerSeconds) return;
|
||||
if (pagerTask != null) {
|
||||
try { pagerTask.cancel(); } catch (Throwable ignored) { }
|
||||
pagerTask = null;
|
||||
}
|
||||
pagerSeconds = sec;
|
||||
pagerTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> {
|
||||
if (!enabled || notices.isEmpty()) return;
|
||||
int rows = totalRows();
|
||||
int pages = (rows + MAX_SIDEBAR_ROWS - 1) / MAX_SIDEBAR_ROWS;
|
||||
if (pages <= 1) return; // 只有一页 → 无需翻页
|
||||
currentPage = (currentPage + 1) % pages;
|
||||
applyAll();
|
||||
}, 20L * sec, 20L * sec);
|
||||
}
|
||||
/** 按排序位升序(稳定,手动冒泡,规避 JDK26 javac lambda 类型推断崩溃) */
|
||||
private static void sortBySlot(List<Notice> list) {
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
for (int j = i + 1; j < list.size(); j++) {
|
||||
if (list.get(j).slot < list.get(i).slot) {
|
||||
Notice tmp = list.get(i);
|
||||
list.set(i, list.get(j));
|
||||
list.set(j, tmp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 从文本中提取 ```json ... ``` 代码块内容 */
|
||||
private static String extractJsonBlock(String zone) {
|
||||
int fence = zone.indexOf("```");
|
||||
if (fence < 0) return null;
|
||||
int bodyStart = zone.indexOf('\n', fence);
|
||||
if (bodyStart < 0) return null;
|
||||
int fence2 = zone.indexOf("```", bodyStart);
|
||||
if (fence2 < 0) return null;
|
||||
return zone.substring(bodyStart + 1, fence2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 颜色解析:16 色英文名 / #RRGGBB / r=0 g=0 b=0。
|
||||
*
|
||||
* @return 合法色;空或非法返回 null(调用方回退白色)
|
||||
*/
|
||||
static TextColor parseColor(String s) {
|
||||
if (s == null) return null;
|
||||
String t = s.trim().toLowerCase(Locale.ROOT);
|
||||
if (t.isEmpty()) return null;
|
||||
// 16 色英文名(NamedTextColor.NAMES 的键就是小写下划线名)
|
||||
if (NATIVE_COLORS.contains(t)) {
|
||||
NamedTextColor c = NamedTextColor.NAMES.value(t);
|
||||
return c != null ? c : NamedTextColor.WHITE;
|
||||
}
|
||||
// #RRGGBB
|
||||
if (t.matches("#?[0-9a-f]{6}")) {
|
||||
try {
|
||||
return TextColor.fromHexString(t.startsWith("#") ? t : "#" + t);
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// r=0 g=0 b=0
|
||||
Matcher m = Pattern.compile(
|
||||
"(?i)(\\d{1,3})[^0-9]*(\\d{1,3})[^0-9]*(\\d{1,3})")
|
||||
.matcher(t);
|
||||
if (m.find()) {
|
||||
try {
|
||||
int r = Integer.parseInt(m.group(1));
|
||||
int g = Integer.parseInt(m.group(2));
|
||||
int b = Integer.parseInt(m.group(3));
|
||||
if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
|
||||
return TextColor.color(r, g, b);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// ==================== 渲染 ====================
|
||||
|
||||
/**
|
||||
* 把公告板刷给单个玩家(PVP 侧边栏管辖范围内自动避让)。
|
||||
*
|
||||
* @param p 玩家
|
||||
*/
|
||||
public void applyTo(Player p) {
|
||||
applyTo(p, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把公告板刷给单个玩家。
|
||||
*
|
||||
* @param p 玩家
|
||||
* @param force true=无视 PVP 区域/竞技场避让(PVP 模块交还侧边栏时用)
|
||||
*/
|
||||
public void applyTo(Player p, boolean force) {
|
||||
if (p == null || !p.isOnline()) return;
|
||||
if (!force && isInPVPSide(p)) return;
|
||||
try {
|
||||
ScoreboardManager mgr = Bukkit.getScoreboardManager();
|
||||
if (mgr == null) return;
|
||||
// ★ 第36批:开关关闭(开关状态=false/0)→ 归还原版主计分板
|
||||
if (!enabled) {
|
||||
p.setScoreboard(mgr.getMainScoreboard());
|
||||
return;
|
||||
}
|
||||
// ★ 第36批:先把公告拆成「行」,再按当前页切片 ——
|
||||
// 原版侧边栏最多 15 行,超过 15 行自动翻页(翻页时间 秒/页)
|
||||
List<Row> rows = new ArrayList<>();
|
||||
for (Notice n : notices.values()) {
|
||||
List<String> lines = splitLine(n.msg);
|
||||
for (int li = 0; li < lines.size(); li++) {
|
||||
rows.add(new Row(n, lines.get(li)));
|
||||
}
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
// 没有可显示的公告 → 归还原版主计分板
|
||||
p.setScoreboard(mgr.getMainScoreboard());
|
||||
return;
|
||||
}
|
||||
int pages = (rows.size() + MAX_SIDEBAR_ROWS - 1)
|
||||
/ MAX_SIDEBAR_ROWS;
|
||||
int page = currentPage;
|
||||
if (page < 0) page = 0;
|
||||
if (page >= pages) page = pages - 1;
|
||||
int from = page * MAX_SIDEBAR_ROWS;
|
||||
int to = Math.min(from + MAX_SIDEBAR_ROWS, rows.size());
|
||||
|
||||
Scoreboard sb = mgr.getNewScoreboard();
|
||||
Objective obj = sb.registerNewObjective(
|
||||
OBJECTIVE, Criteria.DUMMY, legacy(TITLE));
|
||||
obj.setDisplaySlot(DisplaySlot.SIDEBAR);
|
||||
// 右侧不显示分数数字(公告板只展示内容)
|
||||
try {
|
||||
obj.numberFormat(NumberFormat.blank());
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
int row = 0; // 本页已渲染行数
|
||||
for (int i = from; i < to; i++) {
|
||||
Row r = rows.get(i);
|
||||
// entry 用纯 ASCII 唯一串(避免同名条目塌缩)
|
||||
String entry = "n" + row;
|
||||
Score sc = obj.getScore(entry);
|
||||
// 侧边栏默认 descending(高分在上)→ 分数递减,本页首行排最上
|
||||
sc.setScore(MAX_SIDEBAR_ROWS - row);
|
||||
try {
|
||||
sc.customName(buildComponent(r.n, r.line));
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
row++;
|
||||
}
|
||||
p.setScoreboard(sb);
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[公告板] 渲染失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建条目显示组件。
|
||||
*
|
||||
* <p>原版侧边栏<b>不解析 entry 里的 § 码</b>,所以颜色/点击事件
|
||||
* 全部通过 {@code customName} 下发。msg 内部允许写 § 颜色码
|
||||
* (legacy 解析),其优先级高于 color 参数 —— 与原版文本语义一致。
|
||||
*/
|
||||
private static Component buildComponent(Notice n, String line) {
|
||||
Component comp = legacy(line);
|
||||
if (n.color != null) {
|
||||
comp = comp.color(n.color);
|
||||
}
|
||||
if (n.url != null) {
|
||||
comp = comp.clickEvent(ClickEvent.openUrl(n.url))
|
||||
.hoverEvent(HoverEvent.showText(
|
||||
Component.text(n.url)));
|
||||
}
|
||||
return comp;
|
||||
}
|
||||
|
||||
/** legacy § 文本 → Component(解析失败退化为纯文本) */
|
||||
private static Component legacy(String s) {
|
||||
if (s == null || s.isEmpty()) return Component.empty();
|
||||
try {
|
||||
return LegacyComponentSerializer.legacySection().deserialize(s);
|
||||
} catch (Throwable t) {
|
||||
return Component.text(s);
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新所有在线玩家 */
|
||||
public void applyAll() {
|
||||
for (Player p : Bukkit.getOnlinePlayers()) {
|
||||
applyTo(p);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家是否在 PVP 侧边栏管辖范围内(PVP 区域 / PVP 竞技场)。
|
||||
* 公告板据此避让 —— PVP 模块的侧边栏优先。
|
||||
*/
|
||||
private boolean isInPVPSide(Player p) {
|
||||
if (p == null) return false;
|
||||
try {
|
||||
if (plugin.getPVPManager() != null
|
||||
&& plugin.getPVPManager().isInPVPRegion(p.getName())) {
|
||||
return true;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
try {
|
||||
if (plugin.getPVPArenaManager() != null
|
||||
&& plugin.getPVPArenaManager().isInPVPArena(p.getName())) {
|
||||
return true;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==================== 事件 ====================
|
||||
|
||||
/**
|
||||
* 切换世界后重刷公告板(部分世界/插件会重置玩家的计分板)。
|
||||
*/
|
||||
@EventHandler
|
||||
public void onWorldChange(PlayerChangedWorldEvent e) {
|
||||
final Player p = e.getPlayer();
|
||||
Bukkit.getScheduler().runTask(plugin, () -> applyTo(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* 超过 15 个字符 → 截断换行(每行最多 15 字,超出的另起一行)。
|
||||
*
|
||||
* @return 该条公告占的行
|
||||
*/
|
||||
static List<String> splitLine(String msg) {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (msg == null || msg.isEmpty()) return out;
|
||||
int idx = 0;
|
||||
while (idx < msg.length()) {
|
||||
int end = Math.min(idx + MAX_LINE_CHARS, msg.length());
|
||||
out.add(msg.substring(idx, end));
|
||||
idx = end;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,33 @@ public class AreaCLIManager {
|
||||
return Component.text(text).color(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第30批:拉黑玩家禁止管理已有领地(降级访客后不再有任何管理权)。
|
||||
* @return true=被拦截(调用方应直接 return)
|
||||
*/
|
||||
private boolean denyIfBlacklisted(Player p) {
|
||||
if (p == null) return false;
|
||||
BlacklistManager bl = plugin.getBlacklist();
|
||||
if (bl == null) return false;
|
||||
if (!bl.isBlacklisted(p.getName())) return false;
|
||||
p.sendMessage("§c§l[罪人] §f你已被拉黑,禁止管理领地");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第30批:禁止把被拉黑的玩家加进领地授权名单。
|
||||
* @return true=目标玩家在黑名单(调用方应直接 return)
|
||||
*/
|
||||
private boolean denyIfTargetBlacklisted(Player p, String targetPlayer) {
|
||||
if (targetPlayer == null || targetPlayer.isEmpty()) return false;
|
||||
BlacklistManager bl = plugin.getBlacklist();
|
||||
if (bl == null) return false;
|
||||
if (!bl.isBlacklisted(targetPlayer)) return false;
|
||||
p.sendMessage("§c§l[罪人] §f玩家 §e" + targetPlayer
|
||||
+ " §f已被拉黑,不能被授予领地权限");
|
||||
return true;
|
||||
}
|
||||
|
||||
public static Component header(String title) {
|
||||
return Component.text("\n§6§l═══════ " + title + " ═══════\n");
|
||||
}
|
||||
@@ -316,6 +343,7 @@ public class AreaCLIManager {
|
||||
// ==================== 领地管理 ====================
|
||||
|
||||
public void showLandManage(Player p, String landName, int permPage) {
|
||||
if (denyIfBlacklisted(p)) return;
|
||||
savePageInfo(p, "manage", landName, 1);
|
||||
|
||||
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
|
||||
@@ -354,6 +382,9 @@ public class AreaCLIManager {
|
||||
// ★ 新增:领地公告管理
|
||||
p.sendMessage(clickableAction("📢", "领地公告(进出提示消息)",
|
||||
"/protect cli announcement " + land.name + " 1"));
|
||||
// ★ 新增:违禁品管理面板(2026-10-11)
|
||||
p.sendMessage(clickableAction("🚫", "违禁品管理(禁带物品/没收计数/退出归还)",
|
||||
"/protect cli confiscate " + land.name));
|
||||
// ★ 新增:领地改名
|
||||
long[] cooldown = areaProtect.getRenameCooldown(p);
|
||||
String renameHint;
|
||||
@@ -529,6 +560,7 @@ public class AreaCLIManager {
|
||||
// ==================== 切换权限 ====================
|
||||
|
||||
public void togglePerm(Player p, String landName, String permKey, int page) {
|
||||
if (denyIfBlacklisted(p)) return;
|
||||
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
|
||||
if (land == null) {
|
||||
p.sendMessage(Component.text("§c领地不存在: " + landName));
|
||||
@@ -586,6 +618,7 @@ public class AreaCLIManager {
|
||||
case "wool_shear": oldState = land.denyWoolShear; land.denyWoolShear = !land.denyWoolShear; break;
|
||||
case "animal_feed": oldState = land.denyAnimalFeeding; land.denyAnimalFeeding = !land.denyAnimalFeeding; break;
|
||||
case "mob_attack": oldState = land.denyMobAttack; land.denyMobAttack = !land.denyMobAttack; break;
|
||||
case "villager_trade": oldState = land.denyVillagerTrade; land.denyVillagerTrade = !land.denyVillagerTrade; break;
|
||||
case "glowing": oldState = land.denyGlowing; land.denyGlowing = !land.denyGlowing; break;
|
||||
case "visitor_teleport": oldState = land.allowVisitorTeleport; land.allowVisitorTeleport = !land.allowVisitorTeleport; break;
|
||||
case "peace_mode": oldState = land.peaceMode; land.peaceMode = !land.peaceMode; break;
|
||||
@@ -647,6 +680,7 @@ public class AreaCLIManager {
|
||||
case "wool_shear": return "denyWoolShear";
|
||||
case "animal_feed": return "denyAnimalFeeding";
|
||||
case "mob_attack": return "denyMobAttack";
|
||||
case "villager_trade": return "denyVillagerTrade";
|
||||
case "glowing": return "denyGlowing";
|
||||
case "peace_mode": return "peaceMode";
|
||||
case "public_building": return "isPublicBuilding";
|
||||
@@ -699,9 +733,11 @@ public class AreaCLIManager {
|
||||
perms.add(new PermItem("hunger", "饥饿", !land.denyHunger));
|
||||
perms.add(new PermItem("all_damage", "所有伤害", !land.denyAllDamage));
|
||||
perms.add(new PermItem("all_effects", "所有效果", !land.denyAllEffects));
|
||||
perms.add(new PermItem("item_frame", "禁止展示框", !land.denyItemFrame));
|
||||
// ★ 2026-10-10 文案动态化:按启停状态显示"允许/禁止交互展示框"
|
||||
perms.add(new PermItem("item_frame", land.denyItemFrame ? "禁止交互展示框" : "允许交互展示框", !land.denyItemFrame));
|
||||
perms.add(new PermItem("redstone", "红石电路(中继器/比较器)", !land.denyRedstoneInteraction));
|
||||
perms.add(new PermItem("door", "禁止门禁(门/按钮/压力板)", !land.denyDoorInteraction));
|
||||
// ★ 2026-10-10 文案动态化:按启停状态显示"允许/禁止门禁交互"
|
||||
perms.add(new PermItem("door", land.denyDoorInteraction ? "禁止门禁(门/按钮/压力板)" : "允许门禁(门/按钮/压力板)", !land.denyDoorInteraction));
|
||||
perms.add(new PermItem("sign_edit", land.denySignEdit ? "禁止编辑告示牌" : "允许编辑告示牌", !land.denySignEdit));
|
||||
perms.add(new PermItem("audio", "音频(音符盒/唱片机)", !land.denyNoteblockJukebox));
|
||||
perms.add(new PermItem("lead", "拴绳使用", !land.denyLead));
|
||||
@@ -711,6 +747,7 @@ public class AreaCLIManager {
|
||||
perms.add(new PermItem("wool_shear", "剪切羊毛/生物", !land.denyWoolShear));
|
||||
perms.add(new PermItem("animal_feed", land.denyAnimalFeeding ? "禁止投喂" : "允许投喂动物", !land.denyAnimalFeeding));
|
||||
perms.add(new PermItem("mob_attack", land.denyMobAttack ? "禁止攻击生物" : "允许攻击生物", !land.denyMobAttack));
|
||||
perms.add(new PermItem("villager_trade", land.denyVillagerTrade ? "禁止村民交易" : "允许村民交易", !land.denyVillagerTrade));
|
||||
perms.add(new PermItem("glowing", "玩家发光", !land.denyGlowing));
|
||||
perms.add(new PermItem("visitor_teleport", "传送", land.allowVisitorTeleport));
|
||||
perms.add(new PermItem("peace_mode", "和平模式", land.peaceMode));
|
||||
@@ -742,9 +779,9 @@ public class AreaCLIManager {
|
||||
case "hunger": return "饥饿";
|
||||
case "all_damage": return "所有伤害";
|
||||
case "all_effects": return "所有效果";
|
||||
case "item_frame": return "禁止展示框";
|
||||
case "item_frame": return "展示框交互";
|
||||
case "redstone": return "红石电路";
|
||||
case "door": return "禁止门禁";
|
||||
case "door": return "门禁交互";
|
||||
case "sign_edit": return "告示牌编辑";
|
||||
case "audio": return "音频";
|
||||
case "lead": return "拴绳使用";
|
||||
@@ -754,6 +791,7 @@ public class AreaCLIManager {
|
||||
case "wool_shear": return "剪切羊毛";
|
||||
case "animal_feed": return "投喂动物";
|
||||
case "mob_attack": return "攻击生物";
|
||||
case "villager_trade": return "村民交易";
|
||||
case "glowing": return "玩家发光";
|
||||
case "peace_mode": return "和平模式";
|
||||
case "public_building": return "公共建筑设施";
|
||||
@@ -1020,6 +1058,8 @@ public class AreaCLIManager {
|
||||
* ★ 切换玩家的管理员身份(仅领地主可操作)
|
||||
*/
|
||||
public void toggleAdmin(Player p, String landName, String targetPlayer, int page) {
|
||||
if (denyIfBlacklisted(p)) return;
|
||||
if (denyIfTargetBlacklisted(p, targetPlayer)) return;
|
||||
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
|
||||
if (land == null) {
|
||||
p.sendMessage(Component.text("§c领地不存在: " + landName));
|
||||
@@ -1107,6 +1147,7 @@ public class AreaCLIManager {
|
||||
* 清除某成员所有per-player权限覆盖
|
||||
*/
|
||||
public void clearPlayerPerm(Player p, String landName, String targetPlayer) {
|
||||
if (denyIfBlacklisted(p)) return;
|
||||
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
|
||||
if (land == null) {
|
||||
p.sendMessage(Component.text("§c领地不存在: " + landName));
|
||||
@@ -1162,9 +1203,9 @@ public class AreaCLIManager {
|
||||
{"hunger", "饥饿", "denyHunger"},
|
||||
{"all_damage", "所有伤害", "denyAllDamage"},
|
||||
{"all_effects", "所有效果", "denyAllEffects"},
|
||||
{"item_frame", "禁止展示框", "denyItemFrame"},
|
||||
{"item_frame", "展示框交互", "denyItemFrame"},
|
||||
{"redstone", "红石电路(中继器/比较器)", "denyRedstoneInteraction"},
|
||||
{"door", "禁止门禁(门/按钮/压力板)", "denyDoorInteraction"},
|
||||
{"door", "门禁交互", "denyDoorInteraction"},
|
||||
{"sign_edit", "禁止编辑告示牌", "denySignEdit"},
|
||||
{"audio", "音频(音符盒/唱片机)", "denyNoteblockJukebox"},
|
||||
{"lead", "拴绳使用", "denyLead"},
|
||||
@@ -1174,6 +1215,7 @@ public class AreaCLIManager {
|
||||
{"wool_shear", "剪切羊毛/生物", "denyWoolShear"},
|
||||
{"animal_feed", "投喂动物", "denyAnimalFeeding"},
|
||||
{"mob_attack", "攻击生物", "denyMobAttack"},
|
||||
{"villager_trade", "村民交易", "denyVillagerTrade"},
|
||||
{"glowing", "玩家发光", "denyGlowing"},
|
||||
{"peace_mode", "和平模式", "peaceMode"},
|
||||
{"tp", "传送", "allowTeleport"}
|
||||
@@ -1217,6 +1259,18 @@ public class AreaCLIManager {
|
||||
if (field.equals("denyEnderTeleport")) {
|
||||
name = landDefault ? "禁用末影人行为" : "启用末影人行为";
|
||||
}
|
||||
// ★ denyItemFrame显示名称(2026-10-10 按启停状态动态显示)
|
||||
if (field.equals("denyItemFrame")) {
|
||||
name = landDefault ? "禁止交互展示框" : "允许交互展示框";
|
||||
}
|
||||
// ★ denyVillagerTrade显示名称(2026-10-10 新增村民交易权限)
|
||||
if (field.equals("denyVillagerTrade")) {
|
||||
name = landDefault ? "禁止村民交易" : "允许村民交易";
|
||||
}
|
||||
// ★ denyDoorInteraction显示名称(2026-10-10 按启停状态动态显示)
|
||||
if (field.equals("denyDoorInteraction")) {
|
||||
name = landDefault ? "禁止门禁交互(门/按钮/压力板)" : "允许门禁交互(门/按钮/压力板)";
|
||||
}
|
||||
|
||||
// 如果有per-player覆盖,使用覆盖值;否则使用领地默认
|
||||
if (playerPerms.containsKey(field)) {
|
||||
@@ -1270,6 +1324,7 @@ public class AreaCLIManager {
|
||||
case "denyEnderTeleport": return land.denyEnderTeleport;
|
||||
case "denyWoolShear": return land.denyWoolShear;
|
||||
case "denyAnimalFeeding": return land.denyAnimalFeeding;
|
||||
case "denyVillagerTrade": return land.denyVillagerTrade;
|
||||
case "denyGlowing": return land.denyGlowing;
|
||||
case "peaceMode": return land.peaceMode;
|
||||
case "isPublicBuilding": return land.isPublicBuilding;
|
||||
@@ -2022,4 +2077,133 @@ public class AreaCLIManager {
|
||||
showAnnouncementManagement(p, landName, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== 违禁品管理面板(2026-10-11) ====================
|
||||
|
||||
/**
|
||||
* ★ 违禁品管理面板(CLI)。
|
||||
*
|
||||
* <p>展示:没收计数、当前访客禁带物品列表、退出是否归还开关。
|
||||
* 领地主/管理员可在此配置「访客禁止携带的物品」,并控制退出领地时
|
||||
* 是否退还已没收物品(默认不退)。
|
||||
*/
|
||||
public void showConfiscateManagement(Player p, String landName) {
|
||||
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
|
||||
if (land == null) {
|
||||
p.sendMessage(Component.text("§c领地不存在: " + landName));
|
||||
return;
|
||||
}
|
||||
// 权限检查
|
||||
boolean isOwner = AreaProtection.samePlayer(p.getName(), land.owner);
|
||||
boolean isAdmin = areaProtect.isAreaAdmin(p);
|
||||
if (!isOwner && !isAdmin) {
|
||||
p.sendMessage(Component.text("§c需要领地所有者或管理员权限"));
|
||||
return;
|
||||
}
|
||||
|
||||
p.sendMessage(header("违禁品管理: " + landName));
|
||||
p.sendMessage(Component.text("§7配置访客进入领地时禁止携带的物品"));
|
||||
p.sendMessage(Component.text("§7§l───────────────────────────────"));
|
||||
|
||||
// 没收计数
|
||||
p.sendMessage(Component.text("§a累计没收次数: §f" + land.confiscateCount + " §7次"));
|
||||
|
||||
// 当前禁带物品列表
|
||||
p.sendMessage(Component.text(""));
|
||||
if (land.confiscateItems == null || land.confiscateItems.isEmpty()) {
|
||||
p.sendMessage(Component.text("§7当前访客禁带物品: §c(空)"));
|
||||
} else {
|
||||
p.sendMessage(Component.text("§7当前访客禁带物品(§f" + land.confiscateItems.size() + "§7种):"));
|
||||
for (int i = 0; i < land.confiscateItems.size(); i++) {
|
||||
final int idx = i;
|
||||
String item = land.confiscateItems.get(i);
|
||||
org.bukkit.Material mat = org.bukkit.Material.matchMaterial(item);
|
||||
String cn = mat != null ? " §7" + ItemNameResolver.toChinese(mat) : "";
|
||||
p.sendMessage(Component.empty()
|
||||
.append(Component.text("§f " + (i + 1) + ". " + item + cn + " "))
|
||||
.append(Component.text("§c[移除]")
|
||||
.hoverEvent(HoverEvent.showText(Component.text("§e点击移除该禁带物品")))
|
||||
.clickEvent(ClickEvent.runCommand(
|
||||
"/protect cli confiscateremove " + landName + " " + (i + 1)))));
|
||||
}
|
||||
}
|
||||
|
||||
// 添加物品入口
|
||||
p.sendMessage(Component.text(""));
|
||||
p.sendMessage(clickableAction("➕", "添加禁带物品",
|
||||
"/protect cli confiscateadd " + landName));
|
||||
|
||||
// 退出是否归还开关
|
||||
p.sendMessage(Component.text(""));
|
||||
String retState = land.returnOnLeave ? "§a开启" : "§c关闭";
|
||||
p.sendMessage(Component.empty()
|
||||
.append(Component.text("§a退出领地归还没收物: " + retState))
|
||||
.append(Component.text(" §7(当前" + (land.returnOnLeave ? "开启,退出时退还" : "关闭,退出不退还") + ")")));
|
||||
p.sendMessage(Component.empty()
|
||||
.append(Component.text(land.returnOnLeave ? "§c[关闭归还] " : "§a[开启归还] "))
|
||||
.hoverEvent(HoverEvent.showText(Component.text("§e切换:玩家离开领地时是否退还此前被没收的物品")))
|
||||
.clickEvent(ClickEvent.runCommand(
|
||||
"/protect cli confiscateret " + landName + " " + (land.returnOnLeave ? "off" : "on"))));
|
||||
|
||||
p.sendMessage(Component.text("§7§l───────────────────────────────"));
|
||||
p.sendMessage(clickableAction("◀", "返回领地管理", "/protect cli manage " + landName));
|
||||
p.sendMessage(Component.text("§7§l───────────────────────────────"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始添加禁带物品(等待玩家聊天输入物品ID)
|
||||
*/
|
||||
public void startAddConfiscateItem(Player p, String landName) {
|
||||
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
|
||||
if (land == null) { p.sendMessage(Component.text("§c领地不存在: " + landName)); return; }
|
||||
boolean isOwner = AreaProtection.samePlayer(p.getName(), land.owner);
|
||||
boolean isAdmin = areaProtect.isAreaAdmin(p);
|
||||
if (!isOwner && !isAdmin) { p.sendMessage(Component.text("§c需要领地所有者或管理员权限")); return; }
|
||||
areaProtect.setPendingEffectInput(p.getUniqueId(), landName, "confiscate_add", 3);
|
||||
p.sendMessage(header("添加禁带物品: " + landName));
|
||||
p.sendMessage(Component.text("§e请在聊天栏输入物品名 §7(支持中文,如 §f钻石、黑曜石§7)"));
|
||||
p.sendMessage(Component.text("§7也可输入英文ID: §fdiamond、minecraft:gold_ingot§7;输入 §c取消 §7放弃添加"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除指定序号的禁带物品
|
||||
*/
|
||||
public void removeConfiscateItem(Player p, String landName, String indexStr) {
|
||||
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
|
||||
if (land == null) { p.sendMessage(Component.text("§c领地不存在: " + landName)); return; }
|
||||
boolean isOwner = AreaProtection.samePlayer(p.getName(), land.owner);
|
||||
boolean isAdmin = areaProtect.isAreaAdmin(p);
|
||||
if (!isOwner && !isAdmin) { p.sendMessage(Component.text("§c需要领地所有者或管理员权限")); return; }
|
||||
int idx;
|
||||
try { idx = Integer.parseInt(indexStr); } catch (Exception e) { p.sendMessage(Component.text("§c序号无效")); return; }
|
||||
if (idx < 1 || idx > land.confiscateItems.size()) { p.sendMessage(Component.text("§c序号超出范围")); return; }
|
||||
String removed = land.confiscateItems.remove(idx - 1);
|
||||
areaProtect.saveAreaToDb(land);
|
||||
p.sendMessage(Component.text("§a已移除禁带物品: §f" + removed));
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换「退出领地是否归还没收物」开关
|
||||
*/
|
||||
public void toggleReturnOnLeave(Player p, String landName, String newState) {
|
||||
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
|
||||
if (land == null) {
|
||||
p.sendMessage(Component.text("§c领地不存在: " + landName));
|
||||
return;
|
||||
}
|
||||
boolean isOwner = AreaProtection.samePlayer(p.getName(), land.owner);
|
||||
boolean isAdmin = areaProtect.isAreaAdmin(p);
|
||||
if (!isOwner && !isAdmin) {
|
||||
p.sendMessage(Component.text("§c需要领地所有者或管理员权限"));
|
||||
return;
|
||||
}
|
||||
if (newState.equalsIgnoreCase("on")) {
|
||||
land.returnOnLeave = true;
|
||||
p.sendMessage(Component.text("§a已开启:玩家离开领地时将退还没收的物品"));
|
||||
} else {
|
||||
land.returnOnLeave = false;
|
||||
p.sendMessage(Component.text("§a已关闭:玩家离开领地时不退还没收的物品"));
|
||||
}
|
||||
areaProtect.saveAreaToDb(land);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ public class AreaGUIManager implements Listener {
|
||||
private static final String T_EFFECTS_CLEAR = "§b§l单清效果";
|
||||
private static final String T_EFFECTS_GIVE = "§b§l增益效果";
|
||||
private static final String T_LAND_TRANSFER = "§6§l过户领地";
|
||||
private static final String T_CONFISCATE = "§c§l违禁品管理 - ";
|
||||
|
||||
// 每页显示数量
|
||||
private static final int PAGE_SIZE = 45;
|
||||
@@ -266,6 +267,15 @@ public class AreaGUIManager implements Listener {
|
||||
inv.setItem(33, createItem(Material.NAME_TAG, "§e§l领地改名",
|
||||
renameLore, "", "§e点击输入新名字"));
|
||||
|
||||
// 违禁品管理(位置34,2026-10-11)
|
||||
inv.setItem(34, createItem(Material.BARRIER, "§c§l违禁品管理",
|
||||
"§7配置访客禁止携带的物品",
|
||||
"§7进入回收 · 退出按设置归还",
|
||||
"§7累计没收: §f" + land.confiscateCount + " §7次",
|
||||
"§7退出归还: " + (land.returnOnLeave ? "§a开启" : "§c关闭"),
|
||||
"",
|
||||
"§e点击管理"));
|
||||
|
||||
// 返回按钮(位置48)
|
||||
inv.setItem(48, createItem(Material.ARROW, "§c§l返回领地列表", ""));
|
||||
|
||||
@@ -500,7 +510,8 @@ public class AreaGUIManager implements Listener {
|
||||
{"攻击生物", "denyMobAttack"},
|
||||
{"禁止编辑告示牌", "denySignEdit"},
|
||||
{"玩家发光", "denyGlowing"}, {"传送", "allowTeleport"}, {"和平模式", "peaceMode"},
|
||||
{"耕地破坏", "denyFarmlandTrample"}, {"末影人行为", "denyEnderTeleport"}
|
||||
{"耕地破坏", "denyFarmlandTrample"}, {"末影人行为", "denyEnderTeleport"},
|
||||
{"村民交易", "denyVillagerTrade"}
|
||||
};
|
||||
|
||||
for (String[] def : permDefs) {
|
||||
@@ -537,6 +548,16 @@ public class AreaGUIManager implements Listener {
|
||||
if (field.equals("denyEnderTeleport")) {
|
||||
name = landDefault ? "禁用末影人行为" : "启用末影人行为";
|
||||
}
|
||||
if (field.equals("denyItemFrame")) {
|
||||
name = landDefault ? "禁止交互展示框" : "允许交互展示框";
|
||||
}
|
||||
if (field.equals("denyVillagerTrade")) {
|
||||
name = landDefault ? "禁止村民交易" : "允许村民交易";
|
||||
}
|
||||
// ★ denyDoorInteraction显示名称(2026-10-10 按启停状态动态显示)
|
||||
if (field.equals("denyDoorInteraction")) {
|
||||
name = landDefault ? "禁止门禁交互" : "允许门禁交互";
|
||||
}
|
||||
|
||||
// ★ 对于 allowPickup/allowDrop,enabled=true 表示"允许操作"(即 allow=true)
|
||||
// 其他权限,enabled = true 表示"允许操作"(即 deny=false)
|
||||
@@ -619,6 +640,7 @@ public class AreaGUIManager implements Listener {
|
||||
case "denyAnimalFeeding": return land.denyAnimalFeeding;
|
||||
case "denyGlowing": return land.denyGlowing;
|
||||
case "denySignEdit": return land.denySignEdit;
|
||||
case "denyVillagerTrade": return land.denyVillagerTrade;
|
||||
case "peaceMode": return land.peaceMode;
|
||||
default: return false;
|
||||
}
|
||||
@@ -695,15 +717,18 @@ public class AreaGUIManager implements Listener {
|
||||
perms.add(new PermEntry("饥饿", !land.denyHunger));
|
||||
perms.add(new PermEntry("所有伤害", !land.denyAllDamage));
|
||||
perms.add(new PermEntry("所有效果", !land.denyAllEffects));
|
||||
perms.add(new PermEntry("禁止展示框", !land.denyItemFrame));
|
||||
// ★ 2026-10-10 文案动态化:按启停状态显示"允许/禁止交互展示框"
|
||||
perms.add(new PermEntry(land.denyItemFrame ? "禁止交互展示框" : "允许交互展示框", !land.denyItemFrame));
|
||||
perms.add(new PermEntry("红石电路(按钮/压力板/中继器/比较器)", !land.denyRedstoneInteraction));
|
||||
perms.add(new PermEntry("门禁交互(门/栅栏门)", !land.denyDoorInteraction));
|
||||
// ★ 2026-10-10 文案动态化:按启停状态显示"允许/禁止门禁交互"
|
||||
perms.add(new PermEntry(land.denyDoorInteraction ? "禁止门禁交互(门/栅栏门)" : "允许门禁交互(门/栅栏门)", !land.denyDoorInteraction));
|
||||
perms.add(new PermEntry("音频(音符盒/唱片机)", !land.denyNoteblockJukebox));
|
||||
perms.add(new PermEntry("拴绳使用", !land.denyLead));
|
||||
perms.add(new PermEntry(land.denyCropHarvest ? "禁用农作物收获" : "启用农作物收获", !land.denyCropHarvest));
|
||||
perms.add(new PermEntry("剪切羊毛/生物", !land.denyWoolShear));
|
||||
perms.add(new PermEntry(land.denyAnimalFeeding ? "禁止投喂" : "允许投喂动物", !land.denyAnimalFeeding));
|
||||
perms.add(new PermEntry(land.denyMobAttack ? "禁止攻击生物" : "允许攻击生物", !land.denyMobAttack));
|
||||
perms.add(new PermEntry(land.denyVillagerTrade ? "禁止村民交易" : "允许村民交易", !land.denyVillagerTrade));
|
||||
perms.add(new PermEntry(land.denySignEdit ? "禁止编辑告示牌" : "允许编辑告示牌", !land.denySignEdit));
|
||||
perms.add(new PermEntry("玩家发光", !land.denyGlowing));
|
||||
perms.add(new PermEntry(land.denyFarmlandTrample ? "禁用耕地破坏" : "启用耕地破坏", !land.denyFarmlandTrample));
|
||||
@@ -758,9 +783,9 @@ public class AreaGUIManager implements Listener {
|
||||
case "饥饿": land.denyHunger = !land.denyHunger; break;
|
||||
case "所有伤害": land.denyAllDamage = !land.denyAllDamage; break;
|
||||
case "所有效果": land.denyAllEffects = !land.denyAllEffects; break;
|
||||
case "禁止展示框": land.denyItemFrame = !land.denyItemFrame; break;
|
||||
case "禁止展示框": case "允许交互展示框": case "禁止交互展示框": land.denyItemFrame = !land.denyItemFrame; break;
|
||||
case "红石电路(按钮/压力板/中继器/比较器)": land.denyRedstoneInteraction = !land.denyRedstoneInteraction; break;
|
||||
case "门禁交互(门/栅栏门)": land.denyDoorInteraction = !land.denyDoorInteraction; break;
|
||||
case "门禁交互(门/栅栏门)": case "禁止门禁交互(门/栅栏门)": case "允许门禁交互(门/栅栏门)": land.denyDoorInteraction = !land.denyDoorInteraction; break;
|
||||
case "音频(音符盒/唱片机)": land.denyNoteblockJukebox = !land.denyNoteblockJukebox; break;
|
||||
case "拴绳使用": land.denyLead = !land.denyLead; break;
|
||||
case "农作物收获": land.denyCropHarvest = !land.denyCropHarvest; break;
|
||||
@@ -769,6 +794,7 @@ public class AreaGUIManager implements Listener {
|
||||
case "剪切羊毛/生物": land.denyWoolShear = !land.denyWoolShear; break;
|
||||
case "禁止投喂": case "允许投喂动物": land.denyAnimalFeeding = !land.denyAnimalFeeding; break;
|
||||
case "禁止攻击生物": case "允许攻击生物": land.denyMobAttack = !land.denyMobAttack; break;
|
||||
case "禁止村民交易": case "允许村民交易": land.denyVillagerTrade = !land.denyVillagerTrade; break;
|
||||
case "禁止编辑告示牌": case "允许编辑告示牌": land.denySignEdit = !land.denySignEdit; break;
|
||||
case "玩家发光": land.denyGlowing = !land.denyGlowing; break;
|
||||
case "禁用耕地破坏": case "启用耕地破坏": land.denyFarmlandTrample = !land.denyFarmlandTrample; break;
|
||||
@@ -991,6 +1017,9 @@ public class AreaGUIManager implements Listener {
|
||||
openLandSettings(p, landName);
|
||||
} else if (raw == 16) {
|
||||
openEffectsManagement(p, landName, 1);
|
||||
} else if (raw == 34) {
|
||||
// ★ 违禁品管理面板(2026-10-11)
|
||||
openConfiscatePanel(p, landName);
|
||||
} else if (raw == 32) {
|
||||
// 过户领地
|
||||
openTransferLandPanel(p, landName);
|
||||
@@ -1048,6 +1077,52 @@ public class AreaGUIManager implements Listener {
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 违禁品管理面板(2026-10-11)
|
||||
if (title.startsWith(T_CONFISCATE)) {
|
||||
event.setCancelled(true);
|
||||
String landName = title.substring(T_CONFISCATE.length()).trim();
|
||||
if (!event.isLeftClick()) return;
|
||||
|
||||
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
|
||||
if (land == null) { p.closeInventory(); return; }
|
||||
|
||||
int[] slots = {10,11,12,13,14,15,16, 19,20,21,22,23,24,25, 28,29,30,31,32,33,34};
|
||||
// 判断点击是否命中某个禁带物品
|
||||
int hitIdx = -1;
|
||||
for (int i = 0; i < slots.length && i < land.confiscateItems.size(); i++) {
|
||||
if (slots[i] == raw) { hitIdx = i; break; }
|
||||
}
|
||||
if (hitIdx >= 0) {
|
||||
String removed = land.confiscateItems.remove(hitIdx);
|
||||
areaProtect.saveAreaToDb(land);
|
||||
p.sendMessage("§a§l[违禁品] §f已移除禁带物品: §e" + removed);
|
||||
openConfiscatePanel(p, landName);
|
||||
return;
|
||||
}
|
||||
if (raw == 22) {
|
||||
// 切换退出归还开关
|
||||
land.returnOnLeave = !land.returnOnLeave;
|
||||
areaProtect.saveAreaToDb(land);
|
||||
p.sendMessage(land.returnOnLeave
|
||||
? "§a§l[违禁品] §f已开启:玩家离开领地时退还没收物"
|
||||
: "§c§l[违禁品] §f已关闭:玩家离开领地时不退还没收物");
|
||||
openConfiscatePanel(p, landName);
|
||||
return;
|
||||
}
|
||||
if (raw == 40) {
|
||||
// 添加禁带物品 → 关闭GUI转聊天输入
|
||||
p.closeInventory();
|
||||
areaProtect.setPendingEffectInput(p.getUniqueId(), landName, "confiscate_add", 1);
|
||||
p.sendMessage("§e§l[违禁品] §f请在聊天栏输入物品名(支持中文,如 §e钻石、黑曜石§f),输入 §c取消 §7放弃");
|
||||
return;
|
||||
}
|
||||
if (raw == 48) {
|
||||
openLandManage(p, landName);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 成员列表
|
||||
if (title.startsWith(T_MEMBER_LIST)) {
|
||||
event.setCancelled(true);
|
||||
@@ -1176,7 +1251,8 @@ public class AreaGUIManager implements Listener {
|
||||
{"攻击生物", "denyMobAttack"},
|
||||
{"禁止编辑告示牌", "denySignEdit"},
|
||||
{"玩家发光", "denyGlowing"}, {"传送", "allowTeleport"}, {"和平模式", "peaceMode"},
|
||||
{"耕地破坏", "denyFarmlandTrample"}, {"末影人行为", "denyEnderTeleport"}
|
||||
{"耕地破坏", "denyFarmlandTrample"}, {"末影人行为", "denyEnderTeleport"},
|
||||
{"村民交易", "denyVillagerTrade"}
|
||||
};
|
||||
|
||||
if (raw < permDefs.length) {
|
||||
@@ -2297,6 +2373,70 @@ public class AreaGUIManager implements Listener {
|
||||
p.openInventory(inv);
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 违禁品管理面板(2026-10-11)。
|
||||
*
|
||||
* <p>展示没收计数、当前访客禁带物品列表(点击移除)、添加入口、
|
||||
* 以及「退出领地是否归还没收物」开关(默认关闭=不退)。
|
||||
*/
|
||||
public void openConfiscatePanel(Player p, String landName) {
|
||||
managingLand.put(p.getUniqueId(), landName);
|
||||
Inventory inv = Bukkit.createInventory(null, 54, T_CONFISCATE + landName);
|
||||
fillBg(inv);
|
||||
|
||||
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
|
||||
if (land == null) {
|
||||
p.sendMessage("§c领地不存在");
|
||||
p.closeInventory();
|
||||
return;
|
||||
}
|
||||
|
||||
// 没收计数(位置4)
|
||||
inv.setItem(4, createItem(Material.HOPPER, "§e§l没收计数",
|
||||
"§7该领地累计没收违禁品次数",
|
||||
"§7累计: §f" + land.confiscateCount + " §7次"));
|
||||
|
||||
// 退出归还开关(位置22)
|
||||
inv.setItem(22, createItem(land.returnOnLeave ? Material.LIME_DYE : Material.GRAY_DYE,
|
||||
land.returnOnLeave ? "§a§l退出归还: 开启" : "§c§l退出归还: 关闭",
|
||||
"§7玩家离开领地时是否退还没收物",
|
||||
"§7当前: " + (land.returnOnLeave ? "§a开启,退出时退还" : "§c关闭,退出不退还"),
|
||||
"",
|
||||
"§e点击切换"));
|
||||
|
||||
// 禁带物品列表(位置 10-16, 19-25, 28-34)
|
||||
int[] slots = {10,11,12,13,14,15,16, 19,20,21,22,23,24,25, 28,29,30,31,32,33,34};
|
||||
java.util.List<String> items = land.confiscateItems;
|
||||
for (int i = 0; i < items.size() && i < slots.length; i++) {
|
||||
final int idx = i;
|
||||
String item = items.get(i);
|
||||
org.bukkit.Material mat = org.bukkit.Material.matchMaterial(item);
|
||||
inv.setItem(slots[i], createItem(
|
||||
mat != null ? mat : Material.BARRIER,
|
||||
"§c" + ItemNameResolver.toChinese(mat),
|
||||
"§7英文ID: §f" + item,
|
||||
"§7访客禁止携带此物品",
|
||||
"",
|
||||
"§e点击移除"));
|
||||
}
|
||||
|
||||
// 添加物品按钮(位置40)
|
||||
inv.setItem(40, createItem(Material.EMERALD, "§a§l添加禁带物品",
|
||||
"§7在聊天栏输入物品名(支持中文)",
|
||||
"§7示例: 钻石、黑曜石、diamond",
|
||||
"",
|
||||
"§e点击添加"));
|
||||
|
||||
// 当前数量提示(位置49)
|
||||
inv.setItem(49, createItem(Material.PAPER, "§7§l共 " + items.size() + " 种禁带物品",
|
||||
"§7列表已满时请先移除"));
|
||||
|
||||
// 返回按钮(位置48)
|
||||
inv.setItem(48, createItem(Material.ARROW, "§c§l返回管理领地", ""));
|
||||
|
||||
p.openInventory(inv);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理过户领地GUI点击事件
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,890 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.scheduler.BukkitRunnable;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.Writer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Random;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* ★ 第十六批:游戏内轮询封禁播报(耻辱台)
|
||||
*
|
||||
* 配置文件(plugins/Sdf1_login/ban/ 目录):
|
||||
* - *.json 直接作为配置文件;
|
||||
* - 规则配置.md 优先解 ```json 代码块,解不出再解 markdown 表格;
|
||||
* - 解码全失败 → 备份原文件 + 释放一份新的。
|
||||
* 配置丢失 → 自动补发(saveResource 自愈);
|
||||
* 已存在的 md → 安全升级自动补齐模板新增行(safeUpgradeDoc,第二十一批)。
|
||||
*
|
||||
* ★ 第二十七批:数据源改为原版 banned-players.json(用户明令「这是 MC 唯一的封禁数据来源」)。
|
||||
* 线上事故:旧实现读 Bukkit.getBanList(NAME) 的内存名单,而内存名单在重启/被
|
||||
Web 后台批量改写后与磁盘 banned-players.json 严重不一致(文件里 [] 空,内存里
|
||||
却有几十条)→ 玩家看到「一个都没封却公示一堆人」。
|
||||
* 现改为:每轮播报都重新读 <worldContainer>/banned-players.json 到内存,
|
||||
* 解析每条的 created,过滤掉插件数据目录创建时间之前的全部历史封禁;
|
||||
* 文件缺失 / 语法坏 / 过滤后为空 → 一律闭嘴不播,绝不拿兜底假数据说话。
|
||||
*
|
||||
* 键值:轮播最低间隔(秒) / 轮播最大间隔(秒) / 播报模式 / 播报起点(插件安装之前的封禁不播)
|
||||
* 播报模式:true=由近到远、false=由远到近、0=关闭;
|
||||
* 表格值:由近到远 / 由远到近 / 0。
|
||||
* 间隔或模式为 0 → 关闭播报。
|
||||
*/
|
||||
public class BanBroadcastManager implements Listener {
|
||||
private final Main plugin;
|
||||
private final Random rnd = new Random();
|
||||
|
||||
private File banDir;
|
||||
private File mdFile;
|
||||
private File jsonFile; // *.json 配置(存在则优先)
|
||||
|
||||
private int minInterval = 60;
|
||||
private int maxInterval = 180;
|
||||
private int mode = 1; // 0=关闭 1=由近到远 2=由远到近
|
||||
private int pageSize = 6; // 每轮播报条数(耻辱台一页,一次发6个)
|
||||
private int lastDelaySec = 0; // 本轮实际排定的随机间隔秒数(播报日志用)
|
||||
private long startCutoffMs = 0; // 播报起点(毫秒):只播该时刻之后创建的封禁
|
||||
private boolean startCutoffAuto = true; // 起点未配置 → 自动取插件数据目录创建时间(=插件安装时间)
|
||||
private long autoCutoffMs = -1; // 自动起点缓存(-1=未取)
|
||||
private boolean jsonSyntaxOk = false; // json 文本语法是否有效(区分「语法坏」与「语法好但值空」)
|
||||
private String lastCfgSig = null; // 上轮配置签名(增量重载:无变更不打日志)
|
||||
private volatile boolean timerRunning = false; // 已有待触发的排程(防重复拉起)
|
||||
private volatile boolean stoppedByEmpty = false; // 全员下线导致的停机(等玩家上线恢复)
|
||||
private static BanBroadcastManager instance; // 单插件单实例,供 WebManager 取播报起点
|
||||
|
||||
public BanBroadcastManager(Main plugin) {
|
||||
this.plugin = plugin;
|
||||
instance = this;
|
||||
}
|
||||
|
||||
/** 单例访问(WebManager 推送封禁时取播报起点) */
|
||||
public static BanBroadcastManager get() { return instance; }
|
||||
|
||||
/**
|
||||
* 播报起点(毫秒),0=不过滤。与游戏内播报同一套语义(配置的播报起点 / 自动=插件数据目录创建时间),
|
||||
* 供 PHP 端封禁公示过滤「插件上服务器之前的历史封禁」——两端同源,不各配一套。
|
||||
*/
|
||||
public long getStartCutoff() { return resolveStartCutoff(); }
|
||||
|
||||
// ==================== 27批:banned-players.json 数据模型 ====================
|
||||
|
||||
/** banned-players.json 的一条记录(只取公示需要的字段) */
|
||||
private static final class JsonBan {
|
||||
final String name;
|
||||
final String reason;
|
||||
final String source;
|
||||
final long createdMs;
|
||||
/** 到期毫秒;0 = 永久 */
|
||||
final long expiresMs;
|
||||
|
||||
JsonBan(String name, String reason, String source,
|
||||
long createdMs, long expiresMs) {
|
||||
this.name = name;
|
||||
this.reason = reason;
|
||||
this.source = source;
|
||||
this.createdMs = createdMs;
|
||||
this.expiresMs = expiresMs;
|
||||
}
|
||||
}
|
||||
|
||||
/** 上次成功读到的原始文件签名(内容+长度),用于「数据有没有变」判定与日志 */
|
||||
private String lastFileSig = null;
|
||||
private int lastFileRawCount = 0;
|
||||
private int lastFileKeptCount = 0;
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
public void start() {
|
||||
banDir = new File(plugin.getDataFolder(), "ban");
|
||||
mdFile = new File(banDir, "规则配置.md");
|
||||
jsonFile = findJsonConfig();
|
||||
ensureConfigExists();
|
||||
reloadConfig();
|
||||
scheduleNext();
|
||||
plugin.getServer().getPluginManager().registerEvents(this, plugin);
|
||||
// ★ 二十二批:错位配置检测 —— 曾有人把 规则配置.md/json 放到
|
||||
// plugins/ban/(插件只读 plugins/Sdf1_login/ban/),表现为
|
||||
// 「安全升级没生效」。发现即警告并指明正确路径。
|
||||
try {
|
||||
File wrongDir = new File(
|
||||
plugin.getDataFolder().getParentFile(),
|
||||
"ban");
|
||||
if (wrongDir.isDirectory()) {
|
||||
String[] wrongs = wrongDir.list((d, n) ->
|
||||
n.equals("规则配置.md")
|
||||
|| n.toLowerCase(Locale.ROOT)
|
||||
.endsWith(".json"));
|
||||
if (wrongs != null && wrongs.length > 0) {
|
||||
warn("检测到错位配置目录 plugins/ban/(含 "
|
||||
+ String.join(", ", wrongs)
|
||||
+ ")——本插件只读取 "
|
||||
+ banDir.getPath()
|
||||
+ ",该目录内的配置不会生效!");
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
long cut0 = resolveStartCutoff();
|
||||
File bf0 = bannedPlayersFile();
|
||||
plugin.getLogger().info("[封禁播报] 已启动,模式=" + modeName()
|
||||
+ " 间隔=" + minInterval + "~" + maxInterval + "s 每页" + pageSize + "条"
|
||||
+ " 起点=" + (cut0 > 0 ? new SimpleDateFormat("yyyy-MM-dd").format(new Date(cut0)) : "不限")
|
||||
+ " 数据源=" + (bf0 == null ? "未定位" : bf0.getPath()));
|
||||
List<JsonBan> boot = collectBans();
|
||||
log("启动首筛:banned-players.json 有效封禁 " + boot.size() + " 条");
|
||||
}
|
||||
|
||||
public void shutdown() { /* BukkitRunnable 随插件卸载自动取消 */ }
|
||||
|
||||
/**
|
||||
* ★ 第十九批:定时器生命周期 —— 停止条件只有两个:全员下线、服务器关机/插件卸载。
|
||||
* 配置为「关闭」不停定时器:只静默本轮,下一轮醒来重读配置,
|
||||
* 改回开启(或把间隔从 0 恢复)即自动续播,无需重启。
|
||||
* 全员下线时 scheduleNext 的空服分支停掉排程,第一个玩家上线由此重新拉起。
|
||||
* (start/runTaskLater/PlayerJoinEvent 都在主线程,状态无并发问题)
|
||||
*/
|
||||
@EventHandler
|
||||
public void onPlayerJoin(PlayerJoinEvent e) {
|
||||
if (!stoppedByEmpty || timerRunning) return;
|
||||
stoppedByEmpty = false;
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
// ==================== 配置:自愈 + 双模式解析 ====================
|
||||
|
||||
/** 找 ban/ 目录下任意 *.json 配置文件 */
|
||||
private File findJsonConfig() {
|
||||
File[] fs = banDir == null ? null : banDir.listFiles(
|
||||
(d, n) -> n.toLowerCase(Locale.ROOT).endsWith(".json"));
|
||||
return (fs != null && fs.length > 0) ? fs[0] : null;
|
||||
}
|
||||
|
||||
/** 配置文件丢失就补一份(自愈);已存在的 md 走安全升级补齐模板新增行 */
|
||||
private void ensureConfigExists() {
|
||||
jsonFile = findJsonConfig(); // ★ 每轮重查:运行中新增/删除的 json 都要跟上
|
||||
try {
|
||||
if (jsonFile != null) {
|
||||
if (!banDir.exists()) banDir.mkdirs();
|
||||
if (!jsonFile.exists()) {
|
||||
plugin.saveResource("ban/" + jsonFile.getName(), false);
|
||||
log("配置文件丢失,已自愈补发: " + jsonFile.getName());
|
||||
}
|
||||
} else if (!mdFile.exists()) {
|
||||
banDir.mkdirs();
|
||||
plugin.saveResource("ban/规则配置.md", false);
|
||||
log("配置文件丢失,已自愈补发: 规则配置.md");
|
||||
}
|
||||
// ★ 第二十一批:已存在的 md 安全升级 —— saveResource(...,false) 对已存在文件
|
||||
// 永不覆盖,jar 模板新增的配置行(如「播报起点」)借此补进原文件;
|
||||
// 说明文字与用户改过的值原样保留(safeUpgradeDoc:先 .bak 逐字节校验,
|
||||
// 备份失败整个取消不动原文件;原子写失败自动还原;无新增行幂等不写文件)。
|
||||
// 有变更由 safeUpgradeDoc 打一条明细,无变更安静 —— 对齐增量日志要求。
|
||||
if (mdFile != null && mdFile.exists()) {
|
||||
java.util.List<String> tpl = ConfigManager.readJarResourceLines(
|
||||
plugin.getResource("ban/规则配置.md"));
|
||||
if (tpl != null) {
|
||||
plugin.getConfig2().safeUpgradeDoc(mdFile, tpl, "封禁播报规则");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
warn("自愈补发失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 重新加载配置(json 优先 → md json块 → md 表格 → 备份重置)。
|
||||
* ★ 第二十批:增量重载 —— 生效配置与上一轮逐项对比:
|
||||
* 无变更一个字不打(安静执行随机计时,到点该干嘛干嘛);
|
||||
* 有变更才打一条明细(模式/间隔/起点/来源 哪里从什么变成什么)。 */
|
||||
public void reloadConfig() {
|
||||
// 进门快照(对比基线):上一轮生效值
|
||||
final int oMin = minInterval, oMax = maxInterval, oMode = mode;
|
||||
final long oCut = startCutoffMs;
|
||||
final boolean oAuto = startCutoffAuto;
|
||||
final String oldSig = lastCfgSig;
|
||||
String src = null; // 本轮配置源(变更明细用)
|
||||
try {
|
||||
// ★ 懒重载(每次播报窗口执行一次):先回出厂默认再解析文件 ——
|
||||
// 文件里新改的值、被删掉/清空的项都能如实读到(读到的就是文件里最新的);
|
||||
// 开关(播报模式)决定这次报/不报,最新间隔用于播报完后的下次排程计时。
|
||||
minInterval = 60;
|
||||
maxInterval = 180;
|
||||
mode = 1;
|
||||
startCutoffAuto = true;
|
||||
startCutoffMs = 0;
|
||||
jsonFile = findJsonConfig(); // ★ 每轮重查(不再只在 null 时查一次)
|
||||
if (jsonFile != null && jsonFile.exists()) {
|
||||
String txt = readFile(jsonFile);
|
||||
if (parseJsonText(txt)) {
|
||||
src = "json:" + jsonFile.getName();
|
||||
} else if (jsonSyntaxOk) {
|
||||
// ★ 第十八批:语法有效但间隔值为空(文档语义「表格/json 二者选一填」)
|
||||
// → 继承 json 里已解析的播报模式/播报起点,间隔回退 md 表格
|
||||
// (只取间隔,绝不覆盖已继承的模式/起点)或出厂默认,不判损毁。
|
||||
boolean iv = false;
|
||||
if (mdFile != null && mdFile.exists()) {
|
||||
String md0 = readFile(mdFile);
|
||||
if (md0 != null) iv = parseIntervalTable(md0);
|
||||
}
|
||||
normalize();
|
||||
src = "json继承" + (iv ? "+md间隔" : "+默认间隔") + ":" + jsonFile.getName();
|
||||
} else {
|
||||
// 语法真损坏 → 备份+移除,继续回退 md 分支(不再就此 return)
|
||||
backupAndReset(jsonFile, jsonFile.getName());
|
||||
jsonFile = null;
|
||||
}
|
||||
}
|
||||
if (src == null) {
|
||||
if (!mdFile.exists()) { ensureConfigExists(); }
|
||||
String md = readFile(mdFile);
|
||||
if (md != null) {
|
||||
String jsonBlock = extractJsonBlock(md);
|
||||
if (jsonBlock != null && parseJsonText(jsonBlock)) {
|
||||
src = "md内嵌json";
|
||||
} else if (parseTableText(md)) {
|
||||
src = "md表格";
|
||||
}
|
||||
}
|
||||
if (src == null) {
|
||||
// 全部解码失败 → 备份原文件 + 释放一份新的(低频真事件,仍打日志)
|
||||
backupAndReset(mdFile, "规则配置.md");
|
||||
src = "出厂重置";
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
warn("reloadConfig 异常: " + e.getMessage());
|
||||
src = "异常";
|
||||
}
|
||||
// ★ 增量日志:签名 = 配置源 + 全部生效值;与上一轮完全一致 → 安静
|
||||
String sig = src + "|" + mode + "|" + minInterval + "|" + maxInterval + "|"
|
||||
+ (startCutoffAuto ? "auto" : startCutoffMs);
|
||||
if (sig.equals(oldSig)) { return; }
|
||||
lastCfgSig = sig;
|
||||
if (oldSig == null) { return; } // 首轮只记基线(start()「已启动」总览已覆盖,不重复刷屏)
|
||||
StringBuilder sb = new StringBuilder("配置变更(").append(src).append("):");
|
||||
if (oMode != mode) {
|
||||
sb.append(" 模式 ").append(modeNameOf(oMode)).append("→").append(modeName());
|
||||
}
|
||||
if (oMin != minInterval || oMax != maxInterval) {
|
||||
sb.append(" 间隔 ").append(oMin).append("~").append(oMax).append("s→")
|
||||
.append(minInterval).append("~").append(maxInterval).append("s");
|
||||
}
|
||||
if (oAuto != startCutoffAuto || oCut != startCutoffMs) {
|
||||
sb.append(" 起点 ").append(cutText(oAuto, oCut))
|
||||
.append("→").append(cutText(startCutoffAuto, startCutoffMs));
|
||||
}
|
||||
String oldSrc = oldSig.substring(0, oldSig.indexOf('|'));
|
||||
if (!oldSrc.equals(src)) {
|
||||
sb.append(" 来源 ").append(oldSrc).append("→").append(src);
|
||||
}
|
||||
log(sb.toString());
|
||||
}
|
||||
|
||||
/** 模式码→文字(增量日志对比旧值用;modeName() 取当前字段) */
|
||||
private String modeNameOf(int m) {
|
||||
return m == 0 ? "关闭" : (m == 1 ? "由近到远" : "由远到近");
|
||||
}
|
||||
|
||||
/** 起点状态→文字(增量日志用):auto=自动 / 时间戳=日期 / 0=不限 */
|
||||
private String cutText(boolean auto, long ms) {
|
||||
if (auto) return "自动";
|
||||
return ms > 0 ? new SimpleDateFormat("yyyy-MM-dd").format(new Date(ms)) : "不限";
|
||||
}
|
||||
|
||||
/** 从 md 提取 ```json 代码块内容(不含围栏) */
|
||||
private String extractJsonBlock(String md) {
|
||||
Pattern p = Pattern.compile("```json\\s*\\n(.*?)\\n\\s*```", Pattern.DOTALL);
|
||||
Matcher m = p.matcher(md);
|
||||
return m.find() ? m.group(1).trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 json 文本(键:最小间隔/最大间隔/播报模式)。
|
||||
* 出厂 规则配置.md 的 json 块值为空字符串而表格有真值(文档语义:二者选一填),
|
||||
* 因此仅当 json 里出现「非空间隔值」才认 json 生效;
|
||||
* 只有播报模式、没有任何间隔键的纯 json 也认生效(间隔走默认)。
|
||||
* @return true = json 配置生效;false = 视为未配置,调用方回退表格
|
||||
*/
|
||||
private boolean parseJsonText(String txt) {
|
||||
jsonSyntaxOk = false;
|
||||
try {
|
||||
JsonObject o = JsonParser.parseString(txt).getAsJsonObject();
|
||||
jsonSyntaxOk = true; // 语法解析成功(值是否为空另行判定)
|
||||
boolean hasIntervalKey = o.has("最小间隔") || o.has("最大间隔")
|
||||
|| o.has("轮播最低间隔") || o.has("轮播最大间隔");
|
||||
boolean anyInterval = false;
|
||||
Integer v;
|
||||
if ((v = jsonInt(o, "最小间隔")) != null) { minInterval = v; anyInterval = true; }
|
||||
if ((v = jsonInt(o, "最大间隔")) != null) { maxInterval = v; anyInterval = true; }
|
||||
if ((v = jsonInt(o, "轮播最低间隔")) != null) { minInterval = v; anyInterval = true; }
|
||||
if ((v = jsonInt(o, "轮播最大间隔")) != null) { maxInterval = v; anyInterval = true; }
|
||||
boolean modePresent = false;
|
||||
if (o.has("播报模式") && !o.get("播报模式").isJsonNull()) {
|
||||
mode = parseMode(o.get("播报模式").getAsString());
|
||||
modePresent = true;
|
||||
}
|
||||
if (o.has("播报起点") && !o.get("播报起点").isJsonNull()) {
|
||||
parseStartCutoff(o.get("播报起点").getAsString());
|
||||
}
|
||||
if (anyInterval) { normalize(); return true; }
|
||||
// 没有可用间隔值:纯「只有播报模式且无间隔键」的 json 也算生效
|
||||
if (modePresent && !hasIntervalKey) { normalize(); return true; }
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** json 取间隔值:键存在且非空才返回(空串/null → null,留给表格或默认值) */
|
||||
private Integer jsonInt(JsonObject o, String key) {
|
||||
try {
|
||||
if (!o.has(key) || o.get(key).isJsonNull()) return null;
|
||||
String s = o.get(key).getAsString();
|
||||
if (s == null || s.trim().isEmpty()) return null;
|
||||
int r = toSec(s);
|
||||
return r >= 0 ? r : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 md 表格(| 名字 | 说明 | 参数值 |) */
|
||||
private boolean parseTableText(String md) {
|
||||
boolean any = false;
|
||||
for (String line : md.split("\n")) {
|
||||
String t = line.trim();
|
||||
if (!t.startsWith("|") || t.startsWith("| ---") || t.startsWith("|---")) continue;
|
||||
String[] cells = t.split("\\|");
|
||||
if (cells.length < 4) continue;
|
||||
String key = cells[1].trim();
|
||||
String val = cells[cells.length - 1].trim();
|
||||
if (key.equals("轮播最低间隔")) { minInterval = toSec(val); any = true; }
|
||||
else if (key.equals("轮播最大间隔")) { maxInterval = toSec(val); any = true; }
|
||||
else if (key.equals("播报模式")) { mode = parseMode(val); any = true; }
|
||||
else if (key.equals("播报起点")) { parseStartCutoff(val); any = true; }
|
||||
}
|
||||
if (any) normalize();
|
||||
return any;
|
||||
}
|
||||
|
||||
/** 只从 md 表格取间隔值(不碰播报模式/播报起点 —— 继承 json 时仅回退间隔用) */
|
||||
private boolean parseIntervalTable(String md) {
|
||||
boolean any = false;
|
||||
for (String line : md.split("\n")) {
|
||||
String t = line.trim();
|
||||
if (!t.startsWith("|") || t.startsWith("| ---") || t.startsWith("|---")) continue;
|
||||
String[] cells = t.split("\\|");
|
||||
if (cells.length < 4) continue;
|
||||
String key = cells[1].trim();
|
||||
String val = cells[cells.length - 1].trim();
|
||||
if (key.equals("轮播最低间隔")) { minInterval = toSec(val); any = true; }
|
||||
else if (key.equals("轮播最大间隔")) { maxInterval = toSec(val); any = true; }
|
||||
}
|
||||
if (any) {
|
||||
if (minInterval < 0) minInterval = 0;
|
||||
if (maxInterval < 0) maxInterval = 0;
|
||||
if (maxInterval > 0 && minInterval > maxInterval) maxInterval = minInterval;
|
||||
}
|
||||
return any;
|
||||
}
|
||||
|
||||
/** 播报模式文本/布尔/数字 → 0/1/2 */
|
||||
private int parseMode(String s) {
|
||||
if (s == null) return 1;
|
||||
String v = s.trim();
|
||||
// ★ 第十八批遗留修复:0 = 关闭(表格文档语义)。
|
||||
// 原实现把 "0" 和 "false" 合并映射成 2(由远到近),
|
||||
// 导致表格填 0 永远关不掉、一直播报 —— 现拆开各归各位。
|
||||
if (v.equals("0")) return 0; // 0=关闭
|
||||
if (v.equalsIgnoreCase("false")) return 2; // false=由远到近
|
||||
if (v.equals("1") || v.equalsIgnoreCase("true")) return 1; // true=由近到远
|
||||
if (v.contains("关闭")) return 0; // 容错:直接写「关闭」
|
||||
if (v.contains("近到远")) return 1; // 由近到远
|
||||
if (v.contains("远到近")) return 2; // 由远到近
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** 秒数文本 → int(容错"60s"/"60秒"/"60") */
|
||||
private int toSec(String s) {
|
||||
String v = s == null ? "" : s.trim().replaceAll("[^0-9]", "");
|
||||
if (v.isEmpty()) return 0;
|
||||
try { return Integer.parseInt(v); } catch (Exception e) { return 0; }
|
||||
}
|
||||
|
||||
/** 归一化:模式为0 或 最大最小间隔都为0 → 关闭;最大<最低 → 补齐(对齐配置说明语义) */
|
||||
private void normalize() {
|
||||
if (minInterval < 0) minInterval = 0;
|
||||
if (maxInterval < 0) maxInterval = 0;
|
||||
if (maxInterval > 0 && minInterval > maxInterval) maxInterval = minInterval;
|
||||
if (mode != 0 && mode != 1 && mode != 2) mode = 1;
|
||||
if (mode == 0 || (minInterval == 0 && maxInterval == 0)) mode = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析「播报起点」:插件安装之前的封禁(历史封禁)不播报。
|
||||
* 支持 yyyy-MM-dd[ HH:mm[:ss]] 日期、10/13 位时间戳;0/留空 = 自动
|
||||
* (自动模式取插件数据目录创建时间,即插件安装上服务器的时间)
|
||||
*/
|
||||
private void parseStartCutoff(String s) {
|
||||
String v = s == null ? "" : s.trim();
|
||||
if (v.isEmpty() || v.equals("0")) {
|
||||
startCutoffAuto = true;
|
||||
startCutoffMs = 0;
|
||||
return;
|
||||
}
|
||||
if (v.matches("\\d{10,13}")) {
|
||||
long n = Long.parseLong(v);
|
||||
startCutoffMs = v.length() <= 10 ? n * 1000L : n;
|
||||
startCutoffAuto = false;
|
||||
return;
|
||||
}
|
||||
String[] pats = {"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd"};
|
||||
for (String p : pats) {
|
||||
try {
|
||||
java.text.ParsePosition pos = new java.text.ParsePosition(0);
|
||||
Date d = new SimpleDateFormat(p, Locale.ROOT).parse(v, pos);
|
||||
if (d != null && pos.getIndex() == v.length()) {
|
||||
startCutoffMs = d.getTime();
|
||||
startCutoffAuto = false;
|
||||
return;
|
||||
}
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
warn("播报起点无法解析,回退自动: " + v);
|
||||
startCutoffAuto = true;
|
||||
startCutoffMs = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取播报起点(毫秒):>0 表示只播该时刻之后创建的封禁。
|
||||
*
|
||||
* <p>★ 第二十七批:自动模式取插件数据目录(plugins/Sdf1_login)的创建时间
|
||||
* = 插件安装上服务器的时间,插件上服之前的全部历史封禁一律不播。
|
||||
*
|
||||
* <p>★ 第二十八批:数据目录可能被重建/迁移/重装——creationTime 变成当天,
|
||||
* 把之后创建的全部生效封禁一并滤光(实测起点=2026-10-16 → 2026-09-30
|
||||
* 的封禁被滤 → 播报误报「当前无生效封禁」)。故自动起点加下限保护:
|
||||
* 绝不晚于「距今 180 天」——既挡住古早历史封禁(半年前的一律不播),
|
||||
* 又保证目录被重建时近半年内的生效封禁不被误杀。
|
||||
* 手动配置的播报起点(startCutoffAuto=false)不受此保护,完全按配置走。
|
||||
*
|
||||
* <p>旧实现有「距今不足 7 天就放弃过滤」的守卫(当年担心 mtime 兜底不可信),
|
||||
* 结果插件刚装上服务器时自动退化成「不过滤」,把上线前几年的历史封禁全播了出来
|
||||
* —— 这正是用户看到的「一个都没封却公示一堆人」。
|
||||
* 现改为无条件采用 creationTime:只有文件系统根本给不出创建时间(返回 0 /
|
||||
* 抛异常)才退化为不过滤,宁可少播也不误播历史数据。
|
||||
*/
|
||||
private long resolveStartCutoff() {
|
||||
if (!startCutoffAuto) return startCutoffMs;
|
||||
if (autoCutoffMs < 0) {
|
||||
autoCutoffMs = 0;
|
||||
long folderCreated = 0;
|
||||
try {
|
||||
File df = plugin.getDataFolder();
|
||||
if (df != null && df.exists()) {
|
||||
long t = java.nio.file.Files.readAttributes(df.toPath(),
|
||||
java.nio.file.attribute.BasicFileAttributes.class)
|
||||
.creationTime().toMillis();
|
||||
if (t > 0) folderCreated = t;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
warn("读取插件数据目录创建时间失败,本次不过滤历史封禁: "
|
||||
+ e.getMessage());
|
||||
}
|
||||
if (folderCreated > 0) {
|
||||
long cap = System.currentTimeMillis() - MAX_AUTO_CUTOFF_WINDOW_MS;
|
||||
autoCutoffMs = Math.min(folderCreated, cap);
|
||||
if (folderCreated > cap) {
|
||||
warn("插件数据目录创建时间 " + fmtCutoff(folderCreated)
|
||||
+ " 晚于180天前(目录可能被重建/迁移),"
|
||||
+ "自动起点回退为 " + fmtCutoff(cap)
|
||||
+ ",近半年内的生效封禁照常播报"
|
||||
+ "(可在 ban/规则配置.md 手动填「播报起点」精确控制)");
|
||||
}
|
||||
}
|
||||
if (autoCutoffMs <= 0) {
|
||||
warn("取不到插件数据目录创建时间,历史封禁过滤已关闭"
|
||||
+ "(可在 ban/规则配置.md 里手动填「播报起点」锁定时间)");
|
||||
}
|
||||
}
|
||||
return autoCutoffMs;
|
||||
}
|
||||
|
||||
/** 28批:自动播报起点最多回溯 180 天(约半年)——数据目录重建时起点不得过新 */
|
||||
private static final long MAX_AUTO_CUTOFF_WINDOW_MS = 180L * 24 * 3600 * 1000;
|
||||
|
||||
private static String fmtCutoff(long ms) {
|
||||
return new SimpleDateFormat("yyyy-MM-dd").format(new Date(ms));
|
||||
}
|
||||
|
||||
// ==================== 调度 ====================
|
||||
|
||||
/** 排下一次窗口期:随机 [min,max] 秒后播报,再自我reschedule */
|
||||
private void scheduleNext() {
|
||||
int delaySec;
|
||||
if (mode == 0) {
|
||||
// 播报关闭时仍每 60 秒自检一次:配置文件被删则自愈补发、
|
||||
// 运行中把 0 改回正常值可自动恢复播报(无需重启)。
|
||||
// ★ 关闭 ≠ 停机:定时器只在全员下线/关服时才停(第十九批)
|
||||
delaySec = 60;
|
||||
} else {
|
||||
// ★ 每次在[最小间隔,最大间隔]之间随机取一个秒数,不是固定值
|
||||
int span = maxInterval - minInterval;
|
||||
delaySec = minInterval + (span > 0 ? rnd.nextInt(span + 1) : 0);
|
||||
lastDelaySec = delaySec;
|
||||
}
|
||||
long delayTicks = Math.max(20L, delaySec * 20L);
|
||||
timerRunning = true;
|
||||
new BukkitRunnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
timerRunning = false;
|
||||
if (!plugin.isEnabled()) return;
|
||||
// ★ 服务器没人就停机:全员下线是定时器的两个停止条件之一(另一个是关服),
|
||||
// 不播报、不重载、不自愈、不打任何日志,直接停排程 ——
|
||||
// 第一个玩家上线时 onPlayerJoin 重新拉起(配置为关闭同样只静默本轮)
|
||||
if (Bukkit.getOnlinePlayers().isEmpty()) {
|
||||
stoppedByEmpty = true;
|
||||
return;
|
||||
}
|
||||
// ★ 懒重载:每次播报窗口重读配置 —— 开关决定这次报/不报,
|
||||
// 最新间隔用于播报完后的下次排程计时;顺带自愈补发丢失的配置文件
|
||||
ensureConfigExists();
|
||||
reloadConfig();
|
||||
if (mode != 0) {
|
||||
try { broadcastOnce(); }
|
||||
catch (Exception e) { warn("播报异常: " + e.getMessage()); }
|
||||
}
|
||||
scheduleNext();
|
||||
}
|
||||
}.runTaskLater(plugin, delayTicks);
|
||||
}
|
||||
|
||||
// ==================== 播报 ====================
|
||||
|
||||
/**
|
||||
* ★ 27批(用户明令的唯一数据源):每轮都重新读原版 banned-players.json,
|
||||
* 加载到内存做筛选 —— 不再依赖任何内存名单,杜绝「文件是空的却公示一堆人」。
|
||||
*
|
||||
* <p>流程:定位文件 → 读全文 → 内存解析 → 过滤(过期 / created 早于播报起点)
|
||||
* → 返回。文件缺失、为空、语法坏、过滤后一条不剩,一律返回空列表(=闭嘴)。
|
||||
*
|
||||
* <p>created/expires 两种格式都兼容:
|
||||
* ISO「2026-10-16T13:32:58Z」、带时区「2026-10-16 21:06:20 +0800」、
|
||||
* 「yyyy-MM-dd HH:mm:ss」、以及纯数字时间戳(秒/毫秒);
|
||||
* 「forever」/空 = 永久。解析不了的条目按 created=0 处理,
|
||||
* 起点过滤时会被当成历史封禁排除(宁可漏播不误播)。
|
||||
*/
|
||||
private List<JsonBan> collectBans() {
|
||||
List<JsonBan> list = new ArrayList<>();
|
||||
File f = bannedPlayersFile();
|
||||
if (f == null || !f.exists()) {
|
||||
log("未找到 banned-players.json,本轮闭嘴不播");
|
||||
lastFileSig = null;
|
||||
lastFileRawCount = 0;
|
||||
lastFileKeptCount = 0;
|
||||
return list;
|
||||
}
|
||||
String txt = readFile(f);
|
||||
if (txt == null || txt.trim().isEmpty() || txt.trim().equals("[]")) {
|
||||
log("banned-players.json 为空(" + f.length() + " 字节),本轮闭嘴不播");
|
||||
lastFileSig = sigOf(f, txt);
|
||||
lastFileRawCount = 0;
|
||||
lastFileKeptCount = 0;
|
||||
return list;
|
||||
}
|
||||
|
||||
// 内存加载:解析成 JsonBan 列表
|
||||
List<JsonBan> raw = new ArrayList<>();
|
||||
try {
|
||||
com.google.gson.JsonElement root = JsonParser.parseString(txt);
|
||||
if (!root.isJsonArray()) {
|
||||
warn("banned-players.json 根节点不是数组,本轮闭嘴不播");
|
||||
lastFileSig = sigOf(f, txt);
|
||||
return list;
|
||||
}
|
||||
long nowMs = System.currentTimeMillis();
|
||||
for (com.google.gson.JsonElement el : root.getAsJsonArray()) {
|
||||
if (el == null || !el.isJsonObject()) continue;
|
||||
com.google.gson.JsonObject o = el.getAsJsonObject();
|
||||
String name = str(o, "name");
|
||||
if (name.isEmpty()) name = str(o, "uuid");
|
||||
if (name.isEmpty()) continue;
|
||||
raw.add(new JsonBan(name, str(o, "reason"), str(o, "source"),
|
||||
parseBanTime(str(o, "created")),
|
||||
parseBanTime(str(o, "expires"))));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
warn("banned-players.json 解析失败(" + e.getMessage()
|
||||
+ "),本轮闭嘴不播");
|
||||
lastFileSig = sigOf(f, txt);
|
||||
return list;
|
||||
}
|
||||
|
||||
// 内存筛选:未过期 + created 晚于播报起点(起点=插件数据目录创建时间)
|
||||
long nowMs2 = System.currentTimeMillis();
|
||||
long cutoff = resolveStartCutoff();
|
||||
int expiredCnt = 0, historyCnt = 0, timeUnknownCnt = 0;
|
||||
for (JsonBan b : raw) {
|
||||
if (b.expiresMs > 0 && b.expiresMs <= nowMs2) { expiredCnt++; continue; }
|
||||
if (b.createdMs <= 0) { timeUnknownCnt++; } // created 解析不了
|
||||
if (cutoff > 0 && b.createdMs < cutoff) { historyCnt++; continue; }
|
||||
list.add(b);
|
||||
}
|
||||
|
||||
// 「每次都检查是不是最新」:文件签名变了才打日志,避免刷屏
|
||||
String sig = sigOf(f, txt);
|
||||
lastFileRawCount = raw.size();
|
||||
lastFileKeptCount = list.size();
|
||||
if (!sig.equals(lastFileSig)) {
|
||||
lastFileSig = sig;
|
||||
log("已重读 banned-players.json: 原始 " + raw.size() + " 条"
|
||||
+ " → 过滤后 " + list.size() + " 条(起点 "
|
||||
+ (cutoff > 0 ? new SimpleDateFormat("yyyy-MM-dd").format(new Date(cutoff)) : "§c未取到·不过滤")
|
||||
+ ";过期 " + expiredCnt + ",历史 " + historyCnt
|
||||
+ ",时间未知 " + timeUnknownCnt + ",文件 " + sig + ")");
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 原版封禁名单文件:<worldContainer>/banned-players.json(MC 唯一封禁数据源) */
|
||||
private File bannedPlayersFile() {
|
||||
try {
|
||||
File wc = Bukkit.getWorldContainer();
|
||||
if (wc != null) {
|
||||
return new File(wc, "banned-players.json");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
warn("取世界容器失败: " + e.getMessage());
|
||||
}
|
||||
return plugin.getDataFolder() == null ? null
|
||||
: new File(plugin.getDataFolder().getParentFile() == null
|
||||
? plugin.getDataFolder()
|
||||
: plugin.getDataFolder().getParentFile().getParentFile(),
|
||||
"banned-players.json");
|
||||
}
|
||||
|
||||
/** 文件内容签名(长度+哈希),用于「数据有没有变」判定 */
|
||||
private String sigOf(File f, String txt) {
|
||||
int h = txt == null ? 0 : txt.hashCode();
|
||||
return f.length() + "B/" + Integer.toHexString(h);
|
||||
}
|
||||
|
||||
private static String str(com.google.gson.JsonObject o, String k) {
|
||||
try {
|
||||
if (o == null || !o.has(k) || o.get(k).isJsonNull()) return "";
|
||||
String s = o.get(k).getAsString();
|
||||
return s == null ? "" : s;
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* banned-players.json 的时间字段 → 毫秒;解析不了返回 0。
|
||||
* 兼容:ISO8601(带 Z/时区) / "yyyy-MM-dd HH:mm:ss Z" / "yyyy-MM-dd HH:mm:ss"
|
||||
* / "yyyy-MM-dd" / 纯数字(10位=秒,13位=毫秒)。
|
||||
*/
|
||||
private long parseBanTime(String s) {
|
||||
if (s == null) return 0L;
|
||||
String v = s.trim();
|
||||
if (v.isEmpty()) return 0L;
|
||||
if (v.equalsIgnoreCase("forever") || v.equals("永久")) return 0L;
|
||||
// 纯数字时间戳
|
||||
if (v.matches("\\d{10,13}")) {
|
||||
long n = Long.parseLong(v);
|
||||
return v.length() <= 10 ? n * 1000L : n;
|
||||
}
|
||||
String[] pats = {
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
||||
"yyyy-MM-dd'T'HH:mm:ssXXX",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
|
||||
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||
"yyyy-MM-dd'T'HH:mm:ss",
|
||||
"yyyy-MM-dd HH:mm:ss Z",
|
||||
"yyyy-MM-dd HH:mm:ss ZZZZ",
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
"yyyy-MM-dd HH:mm",
|
||||
"yyyy-MM-dd"};
|
||||
for (String pt : pats) {
|
||||
try {
|
||||
java.text.ParsePosition pos = new java.text.ParsePosition(0);
|
||||
Date d = new SimpleDateFormat(pt, Locale.ROOT).parse(v, pos);
|
||||
if (d != null && pos.getIndex() == v.length()) return d.getTime();
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
return 0L;
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第30批:黑名单数据源(群控黑名单 = 软折磨,与封禁公示并列)。
|
||||
* 复用 JsonBan 结构,source 标 "黑名单",理由取黑名单条目的理由。
|
||||
*/
|
||||
private List<JsonBan> collectBlacklistBans() {
|
||||
List<JsonBan> out = new ArrayList<>();
|
||||
BlacklistManager bl = plugin.getBlacklist();
|
||||
if (bl == null) return out;
|
||||
try {
|
||||
for (BlacklistManager.BlackEntry e : bl.getActiveEntries()) {
|
||||
if (e == null || e.name == null || e.name.isEmpty()) continue;
|
||||
out.add(new JsonBan(e.name,
|
||||
e.reason != null && !e.reason.isEmpty()
|
||||
? e.reason : "违反服务器规则",
|
||||
"黑名单",
|
||||
e.createdMs > 0 ? e.createdMs : System.currentTimeMillis(),
|
||||
e.expiresMs));
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
warn("读取黑名单失败: " + ex.getMessage());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 轮播当前页并广播给全服 */
|
||||
private void broadcastOnce() {
|
||||
List<JsonBan> bans = collectBans();
|
||||
// ★ 第30批:并入黑名单玩家(独立数据源,不受 banned-players.json 是否存在影响)
|
||||
int blackCount = 0;
|
||||
try {
|
||||
List<JsonBan> bl = collectBlacklistBans();
|
||||
blackCount = bl.size();
|
||||
bans.addAll(bl);
|
||||
} catch (Exception ignored) {}
|
||||
if (bans.isEmpty()) { log("当前无生效封禁,跳过本轮"); return; }
|
||||
|
||||
// 由近到远:最新封禁在前;由远到近:最早封禁在前
|
||||
// ★ 第33批(#213):黑名单条目一律置顶(source="黑名单"),
|
||||
// 防止其被分页轮播淹没 —— 短时拉黑等不到自己那一页。
|
||||
bans.sort((a, b) -> {
|
||||
boolean ab = "黑名单".equals(a.source);
|
||||
boolean bb = "黑名单".equals(b.source);
|
||||
if (ab != bb) return ab ? -1 : 1;
|
||||
return mode == 1
|
||||
? Long.compare(b.createdMs, a.createdMs) // 由近到远
|
||||
: Long.compare(a.createdMs, b.createdMs); // 由远到近
|
||||
});
|
||||
|
||||
int total = bans.size();
|
||||
int start = cursor % total;
|
||||
int end = Math.min(start + pageSize, total);
|
||||
List<JsonBan> page = bans.subList(start, end);
|
||||
cursor = end % total;
|
||||
|
||||
String head = "§c§l⚠ §6违规封禁公示 §7(共§f" + total + "§7人, 第§f"
|
||||
+ (start / pageSize + 1) + "§7/" + ((total + pageSize - 1) / pageSize) + "§7页)";
|
||||
Bukkit.broadcastMessage(head);
|
||||
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm");
|
||||
for (JsonBan e : page) {
|
||||
String target = e.name;
|
||||
String reason = (e.reason != null && !e.reason.isEmpty())
|
||||
? e.reason : "违反服务器规则";
|
||||
String when = e.createdMs > 0 ? df.format(new Date(e.createdMs)) : "时间未知";
|
||||
String expStr = e.expiresMs > 0 ? ("§7至§f" + df.format(new Date(e.expiresMs)))
|
||||
: "§c永久";
|
||||
Bukkit.broadcastMessage("§c☠ §f" + target + " §7- " + reason
|
||||
+ " §8| §7封于§f" + when + " §8| " + expStr);
|
||||
}
|
||||
Bukkit.broadcastMessage("§7§o请各位玩家引以为戒,遵守服务器规则。");
|
||||
log("本轮已播报 " + page.size() + "/" + total + " 条封禁"
|
||||
+ "(含黑名单 " + blackCount + " 条)"
|
||||
+ "(本次随机间隔 " + lastDelaySec + "s,区间 "
|
||||
+ minInterval + "~" + maxInterval + "s;banned-players.json 原始 "
|
||||
+ lastFileRawCount + " 条,过滤后 " + lastFileKeptCount + " 条)");
|
||||
}
|
||||
|
||||
/** 轮播游标(下一页起点) */
|
||||
private int cursor = 0;
|
||||
|
||||
// ==================== 管理员命令 ====================
|
||||
|
||||
/** 手动重载配置并汇报当前生效值 */
|
||||
public void report(CommandSender to) {
|
||||
reloadConfig();
|
||||
long cut = resolveStartCutoff();
|
||||
to.sendMessage("§a[封禁播报] §7模式=§f" + modeName()
|
||||
+ " §7间隔=§f" + minInterval + "~" + maxInterval + "s"
|
||||
+ " §7每页=§f" + pageSize + "条"
|
||||
+ " §7起点=§f" + (cut > 0 ? new SimpleDateFormat("yyyy-MM-dd").format(new Date(cut)) : "不限")
|
||||
+ (startCutoffAuto ? "§7(自动)" : "")
|
||||
+ (mode == 0 ? " §c(已关闭)" : ""));
|
||||
File bf = bannedPlayersFile();
|
||||
List<JsonBan> now = collectBans();
|
||||
to.sendMessage("§a[封禁播报] §7数据源=§f" + (bf == null ? "未定位"
|
||||
: bf.getName() + (bf.exists() ? "(§f" + bf.length() + "§7字节)" : "(§c不存在§7)"))
|
||||
+ " §7有效封禁=§f" + now.size() + "§7条"
|
||||
+ (now.isEmpty() ? " §8(无数据,保持沉默)" : ""));
|
||||
}
|
||||
|
||||
private String modeName() {
|
||||
return mode == 0 ? "关闭" : (mode == 1 ? "由近到远" : "由远到近");
|
||||
}
|
||||
|
||||
// ==================== 工具 ====================
|
||||
|
||||
private String readFile(File f) {
|
||||
if (f == null || !f.exists()) return null;
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(
|
||||
new FileInputStream(f), StandardCharsets.UTF_8))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line).append('\n');
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
warn("读取失败 " + f.getName() + ": " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 备份原配置文件 + 释放一份新的(自愈) */
|
||||
private void backupAndReset(File f, String resourceName) {
|
||||
try {
|
||||
if (f != null && f.exists()) {
|
||||
String bak = f.getAbsolutePath() + ".bak." + System.currentTimeMillis();
|
||||
copyFile(f, new File(bak));
|
||||
warn("配置解码失败,已备份原文件: " + new File(bak).getName());
|
||||
}
|
||||
// 先删旧文件再从 jar 释放新的
|
||||
if (f != null && f.exists()) f.delete();
|
||||
plugin.saveResource("ban/" + resourceName, false);
|
||||
log("已释放一份新配置: " + resourceName);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// jar 里没有同名模板(如用户自建 cfg.json):原文件已备份并移除,
|
||||
// 不算失败 —— 调用方会继续回退 md / 出厂默认
|
||||
warn("jar 内无模板资源 ban/" + resourceName
|
||||
+ ",原文件已备份并移除,回退其它配置源");
|
||||
} catch (Exception e) {
|
||||
warn("备份/重置失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void copyFile(File src, File dst) throws IOException {
|
||||
try (FileInputStream in = new FileInputStream(src);
|
||||
FileOutputStream out = new FileOutputStream(dst)) {
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(buf)) > 0) out.write(buf, 0, n);
|
||||
}
|
||||
}
|
||||
|
||||
private void log(String s) { plugin.getLogger().info("[封禁播报] " + s); }
|
||||
private void warn(String s) { plugin.getLogger().warning("[封禁播报] " + s); }
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.text.ParsePosition;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* ★ 第30批:群控黑名单(软折磨)。
|
||||
*
|
||||
* <p>数据源:{@code plugins/Sdf1_login/black/黑名单.md} 的「配置区域」表格。
|
||||
*
|
||||
* <p>拉黑时长口径:空 / 0 / 终身 / 永久 = 永久;
|
||||
* {@code yyyy-MM-dd HH:mm} = 到期即该时刻;{@code yyyy-MM-dd} = 当天 23:59:59。
|
||||
*
|
||||
* <p>生效规则:每 5 秒比对文件 mtime+size 做增量重载。
|
||||
* 新增条目 → 立即生效(备份并清空背包 + 冻结债券账户 + 撤销领地管理员身份);
|
||||
* 删除条目 → 立即恢复(解冻债券 + 从最新自动备份恢复背包)。
|
||||
*
|
||||
* <p>指令:{@code /addblack <玩家> [时长] [理由]}、{@code /removeblack <玩家>}。
|
||||
*/
|
||||
public class BlacklistManager {
|
||||
|
||||
private final Main plugin;
|
||||
|
||||
/** 黑名单配置文件:plugins/Sdf1_login/black/黑名单.md */
|
||||
private File mdFile;
|
||||
|
||||
/** 配置区域起止标记 */
|
||||
private static final String ZONE_START = "### ----配置区域开始----";
|
||||
private static final String ZONE_END = "### ----配置区域结束";
|
||||
|
||||
/** 内存黑名单:玩家名 → 条目 */
|
||||
private final Map<String, BlackEntry> entries = new LinkedHashMap<>();
|
||||
|
||||
/** 已施加处罚的玩家 —— 防重复应用(备份/清空只做一次) */
|
||||
private final Set<String> applied = ConcurrentHashMap.newKeySet();
|
||||
|
||||
/** 上次读取的文件签名(mtime + size),用于增量重载判定 */
|
||||
private long lastMtime = -1L;
|
||||
private long lastSize = -1L;
|
||||
|
||||
/** 写回 md 时使用的行尾风格(true=CRLF) */
|
||||
private boolean crlfStyle = true;
|
||||
|
||||
public BlacklistManager(Main plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
|
||||
// ==================== 条目模型 ====================
|
||||
|
||||
/** 黑名单条目 */
|
||||
public static final class BlackEntry {
|
||||
/** 玩家名(原始拼写,写回 md 用) */
|
||||
public final String name;
|
||||
/** 拉黑时长原文 */
|
||||
public final String timeText;
|
||||
/** 理由 */
|
||||
public final String reason;
|
||||
/** 到期毫秒;0 = 永久 */
|
||||
public final long expiresMs;
|
||||
/** 记录时间毫秒 */
|
||||
public final long createdMs;
|
||||
|
||||
BlackEntry(String name, String timeText, String reason,
|
||||
long expiresMs, long createdMs) {
|
||||
this.name = name;
|
||||
this.timeText = timeText;
|
||||
this.reason = reason;
|
||||
this.expiresMs = expiresMs;
|
||||
this.createdMs = createdMs;
|
||||
}
|
||||
|
||||
public boolean isExpired(long nowMs) {
|
||||
return expiresMs > 0L && expiresMs <= nowMs;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
/** 初始化:定位/释放配置文件 → 首次加载 → 启动 5 秒增量重载 */
|
||||
public void init() {
|
||||
try {
|
||||
File dir = new File(plugin.getDataFolder(), "black");
|
||||
if (!dir.exists()) dir.mkdirs();
|
||||
mdFile = new File(dir, "黑名单.md");
|
||||
if (!mdFile.exists()) {
|
||||
try (InputStream in = plugin.getResource("black/黑名单.md")) {
|
||||
if (in != null) {
|
||||
Files.copy(in, mdFile.toPath());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
plugin.getLogger().warning("[黑名单] 模板释放失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[黑名单] 初始化失败: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
reload();
|
||||
// ★ 每 5 秒增量重载(mtime+size 变化才重新解析)
|
||||
Bukkit.getScheduler().runTaskTimer(plugin, () -> {
|
||||
try {
|
||||
if (mdFile == null || !mdFile.exists()) return;
|
||||
long mt = mdFile.lastModified();
|
||||
long sz = mdFile.length();
|
||||
if (mt != lastMtime || sz != lastSize) {
|
||||
reload();
|
||||
}
|
||||
// ★ 第33批(#213):文件没动也要扫 wall-clock 到期
|
||||
// (原实现只比 mtime+size,时长拉黑永远不自动解除)
|
||||
sweepExpired();
|
||||
} catch (Exception ignored) {}
|
||||
}, 100L, 100L);
|
||||
}
|
||||
|
||||
public File getMdFile() {
|
||||
return mdFile;
|
||||
}
|
||||
|
||||
// ==================== 查询 ====================
|
||||
|
||||
/**
|
||||
* 该玩家是否在生效中的黑名单里。
|
||||
* <p>★ 精确匹配(本服离线模式,大小写不同 = 不同玩家)。
|
||||
*/
|
||||
public boolean isBlacklisted(String playerName) {
|
||||
if (playerName == null || playerName.isEmpty()) return false;
|
||||
return findEntry(playerName) != null;
|
||||
}
|
||||
|
||||
/** 精确查条目(过期返回 null) */
|
||||
public BlackEntry findEntry(String playerName) {
|
||||
if (playerName == null || playerName.isEmpty()) return null;
|
||||
BlackEntry e = entries.get(playerName);
|
||||
if (e == null) return null;
|
||||
if (e.isExpired(System.currentTimeMillis())) {
|
||||
entries.remove(playerName);
|
||||
return null;
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
/** 归一化查询(大小写不敏感兜底,仅用于 md 写回定位) */
|
||||
public BlackEntry findEntryLoose(String playerName) {
|
||||
if (playerName == null) return null;
|
||||
BlackEntry exact = findEntry(playerName);
|
||||
if (exact != null) return exact;
|
||||
String want = AreaProtection.normName(playerName);
|
||||
for (BlackEntry e : new ArrayList<>(entries.values())) {
|
||||
if (AreaProtection.normName(e.name).equals(want)) return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 当前生效中的黑名单条目快照(供耻辱台播报) */
|
||||
public List<BlackEntry> getActiveEntries() {
|
||||
long now = System.currentTimeMillis();
|
||||
List<BlackEntry> out = new ArrayList<>();
|
||||
for (BlackEntry e : new ArrayList<>(entries.values())) {
|
||||
if (e.isExpired(now)) {
|
||||
entries.remove(e.name);
|
||||
continue;
|
||||
}
|
||||
out.add(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return getActiveEntries().size();
|
||||
}
|
||||
|
||||
// ==================== 增删(写回 md) ====================
|
||||
|
||||
/**
|
||||
* 添加黑名单条目并写回 md。
|
||||
*
|
||||
* @param timeText 拉黑时长(空/0/终身=永久,日期或日期+时间=到期)
|
||||
* @param reason 理由(可空)
|
||||
* @return null=成功,否则为错误原因
|
||||
*/
|
||||
public String addBlacklist(String playerName, String timeText, String reason) {
|
||||
if (playerName == null || playerName.trim().isEmpty()) {
|
||||
return "玩家名不能为空";
|
||||
}
|
||||
String name = playerName.trim();
|
||||
if (mdFile == null) return "黑名单文件未初始化";
|
||||
|
||||
List<String> lines = readLines();
|
||||
if (lines == null) return "读取黑名单文件失败";
|
||||
|
||||
// 定位配置区域 + 表头
|
||||
int[] zone = locateZone(lines);
|
||||
if (zone == null) return "黑名单文件缺少配置区域标记";
|
||||
int zoneStart = zone[0], zoneEnd = zone[1];
|
||||
int headerIdx = locateHeader(lines, zoneStart, zoneEnd);
|
||||
if (headerIdx < 0) return "黑名单文件缺少表头(| 玩家名字 | 拉黑时长 | 理由 |)";
|
||||
|
||||
// 去掉同名旧行(大小写不敏感)
|
||||
String want = AreaProtection.normName(name);
|
||||
List<String> cleaned = new ArrayList<>();
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
if (i > zoneStart && i < zoneEnd && isRowOf(lines.get(i), want)) {
|
||||
continue;
|
||||
}
|
||||
cleaned.add(lines.get(i));
|
||||
}
|
||||
lines = cleaned;
|
||||
|
||||
// 行数变了 → 重新定位
|
||||
zone = locateZone(lines);
|
||||
if (zone == null) return "黑名单文件缺少配置区域标记";
|
||||
zoneStart = zone[0];
|
||||
zoneEnd = zone[1];
|
||||
headerIdx = locateHeader(lines, zoneStart, zoneEnd);
|
||||
if (headerIdx < 0) return "黑名单文件缺少表头";
|
||||
|
||||
// 插入点 = 表头 + 分隔行之后
|
||||
int insertAt = headerIdx + 1;
|
||||
if (insertAt < zoneEnd && isSeparatorRow(lines.get(insertAt))) {
|
||||
insertAt++;
|
||||
}
|
||||
String safeTime = (timeText == null || timeText.trim().isEmpty()) ? "终身" : timeText.trim();
|
||||
String safeReason = (reason == null || reason.trim().isEmpty()) ? "违反服务器规则" : reason.trim();
|
||||
// 理由里的竖线会破坏表格 → 替换
|
||||
safeReason = safeReason.replace("|", "/").replace("\r", " ").replace("\n", " ");
|
||||
lines.add(insertAt, "| " + name + " | " + safeTime + " | " + safeReason + " |");
|
||||
|
||||
if (!writeLines(lines)) return "写回黑名单文件失败";
|
||||
reload();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除黑名单条目并写回 md。
|
||||
*
|
||||
* @return null=成功(或本来就没有),否则为错误原因
|
||||
*/
|
||||
public String removeBlacklist(String playerName) {
|
||||
if (playerName == null || playerName.trim().isEmpty()) {
|
||||
return "玩家名不能为空";
|
||||
}
|
||||
if (mdFile == null) return "黑名单文件未初始化";
|
||||
List<String> lines = readLines();
|
||||
if (lines == null) return "读取黑名单文件失败";
|
||||
|
||||
int[] zone = locateZone(lines);
|
||||
if (zone == null) return "黑名单文件缺少配置区域标记";
|
||||
int zoneStart = zone[0], zoneEnd = zone[1];
|
||||
|
||||
String want = AreaProtection.normName(playerName.trim());
|
||||
List<String> out = new ArrayList<>();
|
||||
boolean removed = false;
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
if (i > zoneStart && i < zoneEnd && isRowOf(lines.get(i), want)) {
|
||||
removed = true;
|
||||
continue;
|
||||
}
|
||||
out.add(lines.get(i));
|
||||
}
|
||||
if (!removed) {
|
||||
// 文件里没有 → 仍要解冻(可能只冻了账户没进表)
|
||||
restoreBlacklist(playerName.trim());
|
||||
return null;
|
||||
}
|
||||
if (!writeLines(out)) return "写回黑名单文件失败";
|
||||
reload();
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== 解析 ====================
|
||||
|
||||
/** 重新解析 md 并应用差异(新增→处罚,移除→恢复) */
|
||||
public synchronized void reload() {
|
||||
if (mdFile == null) return;
|
||||
Map<String, BlackEntry> parsed = new LinkedHashMap<>();
|
||||
long now = System.currentTimeMillis();
|
||||
List<String> lines = readLines();
|
||||
if (lines != null) {
|
||||
int[] zone = locateZone(lines);
|
||||
if (zone != null) {
|
||||
int zoneStart = zone[0], zoneEnd = zone[1];
|
||||
for (int i = zoneStart + 1; i < zoneEnd; i++) {
|
||||
String line = lines.get(i).trim();
|
||||
if (!line.startsWith("|")) continue;
|
||||
if (isSeparatorRow(line)) continue;
|
||||
String[] cells = splitRow(line);
|
||||
if (cells.length < 1) continue;
|
||||
String name = cells[0].trim();
|
||||
if (name.isEmpty() || name.contains("玩家名字")) continue;
|
||||
String timeText = cells.length > 1 ? cells[1].trim() : "";
|
||||
String reason = cells.length > 2 ? cells[2].trim() : "";
|
||||
long expires = parseExpireMs(timeText, now);
|
||||
if (expires > 0 && expires <= now) continue; // 已过期
|
||||
BlackEntry old = parsed.get(name);
|
||||
long created = old != null ? old.createdMs : now;
|
||||
parsed.put(name, new BlackEntry(name, timeText, reason, expires, created));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 差异应用 =====
|
||||
boolean changed = parsed.size() != entries.size();
|
||||
// 1) 新增 → 施加处罚
|
||||
for (BlackEntry e : parsed.values()) {
|
||||
if (!entries.containsKey(e.name)) {
|
||||
applyBlacklist(e);
|
||||
changed = true;
|
||||
} else {
|
||||
BlackEntry old = entries.get(e.name);
|
||||
if (old == null || old.expiresMs != e.expiresMs
|
||||
|| !old.timeText.equals(e.timeText)
|
||||
|| !old.reason.equals(e.reason)) {
|
||||
changed = true; // 条目内容改了(理由/时长)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2) 移除 → 恢复
|
||||
for (BlackEntry e : new ArrayList<>(entries.values())) {
|
||||
if (!parsed.containsKey(e.name)) {
|
||||
restoreBlacklist(e.name);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
entries.clear();
|
||||
entries.putAll(parsed);
|
||||
|
||||
if (mdFile.exists()) {
|
||||
lastMtime = mdFile.lastModified();
|
||||
lastSize = mdFile.length();
|
||||
}
|
||||
plugin.getLogger().info("[黑名单] 已重载,当前生效 " + parsed.size() + " 人");
|
||||
// ★ 第32批(#208):名单变化 → 立即回响PHP(异步;syncBlacklist 内部 hash 门控兜底)
|
||||
if (changed) {
|
||||
try {
|
||||
WebManager wm = plugin.webManager;
|
||||
if (wm != null) wm.forceSyncBlacklist();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第33批(#213):墙钟到期扫描 —— 时长拉黑自动解除。
|
||||
* <p>根因:5 秒定时器只比对 mtime+size,md 文件不动就永不 reload,
|
||||
* 而 reload 里只在「解析时」过滤已过期行 —— 于是绝对日期/相对时长
|
||||
* 到期后,处罚(applied、债券冻结、md 行)永远挂着 = 用户反馈的
|
||||
* 「配置时长不生效」。本方法独立于文件变化,每 5 秒按当前时间扫。
|
||||
* <p>到期条目:内存移除 → 恢复处罚 → 删 md 对应行 → 回响 PHP。
|
||||
*/
|
||||
private synchronized void sweepExpired() {
|
||||
if (entries.isEmpty()) return;
|
||||
long now = System.currentTimeMillis();
|
||||
List<BlackEntry> expired = new ArrayList<>();
|
||||
for (BlackEntry e : new ArrayList<>(entries.values())) {
|
||||
if (e.isExpired(now)) expired.add(e);
|
||||
}
|
||||
if (expired.isEmpty()) return;
|
||||
|
||||
List<String> names = new ArrayList<>();
|
||||
for (BlackEntry e : expired) {
|
||||
entries.remove(e.name);
|
||||
names.add(e.name);
|
||||
plugin.getLogger().info("[黑名单] 拉黑到期自动解除: " + e.name
|
||||
+ "(" + e.timeText + ")");
|
||||
restoreBlacklist(e.name);
|
||||
}
|
||||
|
||||
// 同步删 md 里对应行(防止文件签名不变、行永远留着)
|
||||
try {
|
||||
List<String> lines = readLines();
|
||||
if (lines != null) {
|
||||
int[] zone = locateZone(lines);
|
||||
if (zone != null) {
|
||||
List<String> out = new ArrayList<>();
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
String line = lines.get(i);
|
||||
boolean drop = false;
|
||||
if (i > zone[0] && i < zone[1] && line.trim().startsWith("|")
|
||||
&& !isSeparatorRow(line.trim())) {
|
||||
for (String n : names) {
|
||||
if (isRowOf(line, AreaProtection.normName(n))) {
|
||||
drop = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!drop) out.add(line);
|
||||
}
|
||||
if (out.size() != lines.size() && writeLines(out)) {
|
||||
// 写回成功 → 立即刷新签名,免得下个 tick 又整表 reload 一遍
|
||||
if (mdFile.exists()) {
|
||||
lastMtime = mdFile.lastModified();
|
||||
lastSize = mdFile.length();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
plugin.getLogger().warning("[黑名单] 删除到期 md 行失败: " + ex.getMessage());
|
||||
}
|
||||
|
||||
// 名单变了 → 回响 PHP(web_player_blacklist 同步移除)
|
||||
try {
|
||||
WebManager wm = plugin.webManager;
|
||||
if (wm != null) wm.forceSyncBlacklist();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉黑时长 → 到期毫秒;0 = 永久。
|
||||
* <p>空 / 0 / 终身 / 永久 / forever → 永久;
|
||||
* {@code yyyy-MM-dd HH:mm[:ss]} → 该时刻;
|
||||
* {@code yyyy-MM-dd} → 当天 23:59:59。
|
||||
*/
|
||||
public static long parseExpireMs(String timeText, long now) {
|
||||
if (timeText == null) return 0L;
|
||||
String t = timeText.trim();
|
||||
if (t.isEmpty()) return 0L;
|
||||
if ("0".equals(t) || "终身".equals(t) || "永久".equals(t)
|
||||
|| "forever".equalsIgnoreCase(t) || "never".equalsIgnoreCase(t)) {
|
||||
return 0L;
|
||||
}
|
||||
String[] pats = {
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
"yyyy-MM-dd HH:mm",
|
||||
"yyyy/MM/dd HH:mm:ss",
|
||||
"yyyy/MM/dd HH:mm"};
|
||||
for (String p : pats) {
|
||||
long v = parseStrict(p, t);
|
||||
if (v > 0L) return v;
|
||||
}
|
||||
// 仅日期 → 当天 23:59:59
|
||||
String[] datePats = {"yyyy-MM-dd", "yyyy/MM/dd"};
|
||||
for (String p : datePats) {
|
||||
long v = parseStrict(p, t);
|
||||
if (v > 0L) {
|
||||
Calendar c = Calendar.getInstance();
|
||||
c.setTimeInMillis(v);
|
||||
c.set(Calendar.HOUR_OF_DAY, 23);
|
||||
c.set(Calendar.MINUTE, 59);
|
||||
c.set(Calendar.SECOND, 59);
|
||||
c.set(Calendar.MILLISECOND, 999);
|
||||
return c.getTimeInMillis();
|
||||
}
|
||||
}
|
||||
// ★ 第33批(#213):相对时长(3天 / 7d / 2小时 / 30分 …)——
|
||||
// 「配置了时长 = 临时拉黑,到期自动解除」。交 DurationParser 统一解析。
|
||||
// 两个护栏:
|
||||
// ① 只认「数字+单位」形态 —— DurationParser 的纯数字分支=Unix秒时间戳(1970),
|
||||
// 裸写 "100" 会从原语义「永久」变成「立即过期」,比不识别更危险;
|
||||
// ② 排除含「年」的中文日期(2026年10月20日)—— 那属绝对日期语义,
|
||||
// DurationParser 会取当天 00:00 = 配完即过期。
|
||||
if (t.matches(".*\\d+\\s*(周|天|日|时|分|秒|小时|分钟|星期|礼拜|w|d|h|m|s|W|D|H|M|S).*")
|
||||
&& !t.contains("年")) {
|
||||
try {
|
||||
long v = DurationParser.parseToExpireMs(t);
|
||||
if (v > 0L) return v;
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
// 解析不了 → 永久(宁可拉黑也不放过)
|
||||
return 0L;
|
||||
}
|
||||
|
||||
private static long parseStrict(String pattern, String value) {
|
||||
try {
|
||||
SimpleDateFormat sdf = new SimpleDateFormat(pattern, Locale.ROOT);
|
||||
sdf.setLenient(false);
|
||||
ParsePosition pos = new ParsePosition(0);
|
||||
Date d = sdf.parse(value, pos);
|
||||
if (d != null && pos.getIndex() == value.length()) return d.getTime();
|
||||
} catch (Exception ignored) {}
|
||||
return 0L;
|
||||
}
|
||||
|
||||
// ==================== 处罚 / 恢复 ====================
|
||||
|
||||
/**
|
||||
* 施加拉黑处罚:备份并清空背包 + 冻结债券账户 + 撤销领地管理员身份(降级访客)。
|
||||
*/
|
||||
public void applyBlacklist(BlackEntry e) {
|
||||
if (e == null) return;
|
||||
final String name = e.name;
|
||||
if (!applied.add(name)) {
|
||||
return; // 已施加过,不重复备份/清空
|
||||
}
|
||||
plugin.getLogger().info("[黑名单] 拉黑生效: " + name
|
||||
+ "(" + e.timeText + " / " + e.reason + ")");
|
||||
|
||||
// ★ 第33批(#213):拉黑当下即时插播一条「拉黑公示」。
|
||||
// 根因:耻辱台轮播 6 页 × 110~400s 随机间隔,短时拉黑
|
||||
// (如测试用 30 秒/13 秒)永远轮不到那一页 = 用户反馈
|
||||
// 「没能进入耻辱台」。插播保证拉黑瞬间全服可见。
|
||||
try {
|
||||
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm");
|
||||
String when = df.format(new Date(e.createdMs > 0 ? e.createdMs
|
||||
: System.currentTimeMillis()));
|
||||
String expStr = e.expiresMs > 0 ? ("§7至§f" + df.format(new Date(e.expiresMs)))
|
||||
: "§c永久";
|
||||
Bukkit.broadcastMessage("§c§l⚠ §6拉黑公示 §c☠ §f" + name
|
||||
+ " §7- " + (e.reason == null || e.reason.isEmpty()
|
||||
? "违反服务器规则" : e.reason)
|
||||
+ " §8| §7拉黑于§f" + when + " §8| " + expStr);
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
// 1) 冻结债券账户(按拉黑到期时间;永久=0)
|
||||
try {
|
||||
BondManager bm = plugin.getBonds();
|
||||
if (bm != null) bm.freezeAccount(name, e.expiresMs);
|
||||
} catch (Exception ex) {
|
||||
plugin.getLogger().warning("[黑名单] 冻结债券失败 (" + name + "): " + ex.getMessage());
|
||||
}
|
||||
|
||||
// 2) 撤销其在所有领地的管理员身份(降级访客)
|
||||
try {
|
||||
AreaProtection ap = plugin.getAreaProtection();
|
||||
if (ap != null) {
|
||||
int n = ap.demoteBlacklistAdmins(name);
|
||||
if (n > 0) {
|
||||
plugin.getLogger().info("[黑名单] 已撤销 " + name + " 的 " + n + " 个领地管理员身份");
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
plugin.getLogger().warning("[黑名单] 撤销领地权限失败 (" + name + "): " + ex.getMessage());
|
||||
}
|
||||
|
||||
// 3) 在线玩家:备份并清空背包
|
||||
Player p = Bukkit.getPlayerExact(name);
|
||||
if (p == null || !p.isOnline()) return;
|
||||
backupAndClear(p, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除拉黑:解冻债券 + 从最新自动备份恢复背包。
|
||||
*/
|
||||
public void restoreBlacklist(String playerName) {
|
||||
if (playerName == null || playerName.isEmpty()) return;
|
||||
if (!applied.remove(playerName)) {
|
||||
// 未必处罚过,但仍确保解冻
|
||||
try {
|
||||
BondManager bm = plugin.getBonds();
|
||||
if (bm != null) bm.unfreezeAccount(playerName);
|
||||
} catch (Exception ignored) {}
|
||||
return;
|
||||
}
|
||||
plugin.getLogger().info("[黑名单] 拉黑解除: " + playerName);
|
||||
try {
|
||||
BondManager bm = plugin.getBonds();
|
||||
if (bm != null) bm.unfreezeAccount(playerName);
|
||||
} catch (Exception ex) {
|
||||
plugin.getLogger().warning("[黑名单] 解冻债券失败 (" + playerName + "): " + ex.getMessage());
|
||||
}
|
||||
Player p = Bukkit.getPlayerExact(playerName);
|
||||
if (p == null || !p.isOnline()) return;
|
||||
restoreInventory(p, playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份并清空背包。
|
||||
* <p>已有备份时不覆盖(保留拉黑时刻那一份),只清空。
|
||||
*/
|
||||
private void backupAndClear(final Player p, final String name) {
|
||||
boolean hasBackup = false;
|
||||
try {
|
||||
hasBackup = plugin.getDb() != null
|
||||
&& plugin.getDb().getLatestAutoBackup(name) != null;
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
if (!hasBackup) {
|
||||
final ItemStack[] contents = p.getInventory().getContents();
|
||||
final ItemStack[] armor = p.getInventory().getArmorContents();
|
||||
final ItemStack[] extra = p.getInventory().getExtraContents();
|
||||
final int level = p.getLevel();
|
||||
final double exp = p.getExp();
|
||||
final String c64, a64, e64;
|
||||
try {
|
||||
c64 = InventorySerializer.toBase64(contents);
|
||||
a64 = InventorySerializer.toBase64(armor);
|
||||
e64 = InventorySerializer.toBase64(extra);
|
||||
} catch (IOException ex) {
|
||||
plugin.getLogger().warning("[黑名单] 背包序列化失败 (" + name + "): " + ex.getMessage());
|
||||
return;
|
||||
}
|
||||
// ★ 落库是阻塞 DB 写 → 异步;写完再回主线程清空(保证备份的是拉黑前快照)
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
try {
|
||||
plugin.getDb().saveAutoBackup(name, c64, a64, e64, level, exp);
|
||||
} catch (Throwable t) {
|
||||
plugin.getLogger().warning("[黑名单] 背包备份落库失败 (" + name + "): " + t.getMessage());
|
||||
}
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
Player cur = Bukkit.getPlayerExact(name);
|
||||
if (cur == null || !cur.isOnline()) return;
|
||||
clearInventory(cur);
|
||||
cur.sendMessage("§c§l[罪人] §f你已被拉黑,背包已清空(已备份)");
|
||||
cur.sendMessage("§7背包物品将在解除拉黑后自动返还");
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 已有备份 → 只清空,不覆盖
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
Player cur = Bukkit.getPlayerExact(name);
|
||||
if (cur == null || !cur.isOnline()) return;
|
||||
clearInventory(cur);
|
||||
cur.sendMessage("§c§l[罪人] §f你已被拉黑,背包已清空");
|
||||
});
|
||||
}
|
||||
|
||||
/** 从最新自动备份恢复背包 */
|
||||
private void restoreInventory(Player p, String name) {
|
||||
String[] data;
|
||||
try {
|
||||
data = plugin.getDb() == null ? null : plugin.getDb().getLatestAutoBackup(name);
|
||||
} catch (Exception ex) {
|
||||
plugin.getLogger().warning("[黑名单] 读取背包备份失败 (" + name + "): " + ex.getMessage());
|
||||
return;
|
||||
}
|
||||
if (data == null) {
|
||||
p.sendMessage("§e§l[罪人] §f拉黑已解除,但没有找到可返还的背包备份");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ItemStack[] contents = InventorySerializer.fromBase64(data[0]);
|
||||
ItemStack[] armor = InventorySerializer.fromBase64(data[1]);
|
||||
ItemStack[] extra = InventorySerializer.fromBase64(data[2]);
|
||||
p.getInventory().setContents(contents);
|
||||
p.getInventory().setArmorContents(armor);
|
||||
p.getInventory().setExtraContents(extra);
|
||||
if (data.length > 3) {
|
||||
try { p.setLevel(Integer.parseInt(data[3])); } catch (Exception ignored) {}
|
||||
}
|
||||
if (data.length > 4) {
|
||||
try { p.setExp((float) Double.parseDouble(data[4])); } catch (Exception ignored) {}
|
||||
}
|
||||
p.sendMessage("§a§l[罪人] §f拉黑已解除,背包已返还");
|
||||
} catch (IOException ex) {
|
||||
plugin.getLogger().warning("[黑名单] 背包反序列化失败 (" + name + "): " + ex.getMessage());
|
||||
p.sendMessage("§e§l[罪人] §f拉黑已解除,但背包返还失败,请联系管理员");
|
||||
}
|
||||
}
|
||||
|
||||
private void clearInventory(Player p) {
|
||||
p.getInventory().clear();
|
||||
p.getInventory().setArmorContents(null);
|
||||
p.getInventory().setExtraContents(null);
|
||||
p.setLevel(0);
|
||||
p.setExp(0);
|
||||
}
|
||||
|
||||
// ==================== md 读写工具 ====================
|
||||
|
||||
/** 读全部行(去掉行尾 \r) */
|
||||
private List<String> readLines() {
|
||||
if (mdFile == null || !mdFile.exists()) return null;
|
||||
List<String> out = new ArrayList<>();
|
||||
try {
|
||||
byte[] raw = Files.readAllBytes(mdFile.toPath());
|
||||
crlfStyle = new String(raw, StandardCharsets.UTF_8).contains("\r\n");
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(
|
||||
new ByteArrayInputStream(raw), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
out.add(line);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
plugin.getLogger().warning("[黑名单] 读取失败: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 写回全部行(保持原行尾风格) */
|
||||
private boolean writeLines(List<String> lines) {
|
||||
if (mdFile == null) return false;
|
||||
String sep = crlfStyle ? "\r\n" : "\n";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String l : lines) {
|
||||
sb.append(l).append(sep);
|
||||
}
|
||||
try {
|
||||
File parent = mdFile.getParentFile();
|
||||
if (parent != null && !parent.exists()) parent.mkdirs();
|
||||
Files.write(mdFile.toPath(), sb.toString().getBytes(StandardCharsets.UTF_8));
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
plugin.getLogger().warning("[黑名单] 写入失败: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 定位配置区域,返回 {起始行, 结束行};找不到返回 null */
|
||||
private int[] locateZone(List<String> lines) {
|
||||
int zoneStart = -1, zoneEnd = -1;
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
String t = lines.get(i).trim();
|
||||
if (t.startsWith(ZONE_START)) {
|
||||
zoneStart = i;
|
||||
} else if (t.startsWith(ZONE_END) && zoneStart >= 0) {
|
||||
zoneEnd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (zoneStart < 0 || zoneEnd < 0) return null;
|
||||
return new int[]{zoneStart, zoneEnd};
|
||||
}
|
||||
|
||||
/** 定位表头行索引;找不到返回 -1 */
|
||||
private int locateHeader(List<String> lines, int zoneStart, int zoneEnd) {
|
||||
for (int i = zoneStart + 1; i < zoneEnd; i++) {
|
||||
String t = lines.get(i).trim();
|
||||
if (t.startsWith("|") && t.contains("玩家名字")) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 表格行是否属于该玩家(第一列匹配,大小写不敏感) */
|
||||
private boolean isRowOf(String line, String wantNorm) {
|
||||
String[] cells = splitRow(line.trim());
|
||||
if (cells.length < 1) return false;
|
||||
String c0 = cells[0].trim();
|
||||
if (c0.isEmpty() || c0.contains("玩家名字")) return false;
|
||||
return AreaProtection.normName(c0).equals(wantNorm);
|
||||
}
|
||||
|
||||
/** 分隔行判断(| --- | --- |) */
|
||||
private boolean isSeparatorRow(String line) {
|
||||
String t = line.trim();
|
||||
if (!t.startsWith("|")) return false;
|
||||
String[] cells = splitRow(t);
|
||||
for (String c : cells) {
|
||||
String v = c.trim();
|
||||
if (!v.isEmpty() && !v.startsWith("---") && !v.matches("[-:]+")) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 拆表格行(去掉首尾竖线,按 | 切分) */
|
||||
private String[] splitRow(String line) {
|
||||
String t = line.trim();
|
||||
if (t.startsWith("|")) t = t.substring(1);
|
||||
if (t.endsWith("|")) t = t.substring(0, t.length() - 1);
|
||||
return t.split("\\|", -1);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,16 @@ public class BondManager {
|
||||
initDB();
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第十批(2026-10-12):暴露 bond.db 连接。
|
||||
* bond_transaction 在 bond.db(财务债券/),不在 login.db——
|
||||
* 跨库查询必须走这里(CommissionManager 日上限/翻倍统计、
|
||||
* InviteManager 存量绑定时间回填均依赖)。
|
||||
*/
|
||||
public Connection getConnection() {
|
||||
return db;
|
||||
}
|
||||
|
||||
// ======================== 数据库初始化 ========================
|
||||
|
||||
private void initDB() {
|
||||
|
||||
@@ -57,6 +57,12 @@ public class CDKManager {
|
||||
*/
|
||||
public String redeem(String code, String player) {
|
||||
try {
|
||||
// ★ 第32批:拉黑检查提到查码之前 —— 被拉黑玩家一律拒绝,
|
||||
// 不管码在本地是否存在,绝不放行进远程验证链
|
||||
if (plugin.getBlacklist() != null
|
||||
&& plugin.getBlacklist().isBlacklisted(player))
|
||||
return "fail:blacklisted";
|
||||
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"SELECT amount,type,used FROM cdk "
|
||||
+ "WHERE code=?");
|
||||
|
||||
@@ -205,14 +205,13 @@ public class ChatFilterManager {
|
||||
return VerificationResult.PENDING;
|
||||
}
|
||||
|
||||
// 检查玩家加入时间是否≤1小时
|
||||
if (isNewPlayerUnder1Hour(p)) {
|
||||
return VerificationResult.NEED_VERIFICATION;
|
||||
}
|
||||
|
||||
// 老玩家:直接永久验证通过
|
||||
verifiedPlayers.add(name);
|
||||
return VerificationResult.VERIFIED;
|
||||
// ★ 2026-10-11 用户裁定(任务6 严重bug修复):
|
||||
// 人机验证豁免必须是【内存态】——服务器重启后,所有人都要重新过一次人机验证。
|
||||
// 旧实现按 register_time(DB 持久化字段)判定「老玩家直接永久验证通过」,
|
||||
// 等于把豁免做成了持久化存储,重启也形同虚设,已废除。
|
||||
// 唯一免验证通道:正版 OAuth 验证通过(上方分支),仅本次运行期内有效,
|
||||
// 重启后随内存一并清空;再次放行请重新走 /mslogin 一键通过。
|
||||
return VerificationResult.NEED_VERIFICATION;
|
||||
}
|
||||
|
||||
/** 全局共享的Random实例,避免频繁创建产生相同序列 */
|
||||
@@ -632,14 +631,17 @@ public class ChatFilterManager {
|
||||
DatabaseManager db = mainPlugin.getDb();
|
||||
if (db == null) return false; // 查不到就不拦截,直接放行
|
||||
|
||||
// register_time 在数据库存的是秒级时间戳
|
||||
// ★ 第十一批:register_time 秒/毫秒混存(PHP 同步行为秒、
|
||||
// 本地注册为毫秒)→ 统一归一化为毫秒。
|
||||
// 旧代码无条件 ×1000:对已是毫秒的行会算出巨大负数
|
||||
// elapsed,恒判「新玩家」。
|
||||
Object regTimeObj = db.getField(p.getName(), "register_time");
|
||||
if (regTimeObj == null) return false; // 没注册记录 → 放行
|
||||
|
||||
long regTimeSeconds = ((Number) regTimeObj).longValue();
|
||||
if (regTimeSeconds == 0) return false; // 注册时间为0 → 放行
|
||||
|
||||
long regTimeMs = regTimeSeconds * 1000;
|
||||
|
||||
long regTimeMs = DurationParser.toMs(
|
||||
((Number) regTimeObj).longValue());
|
||||
if (regTimeMs == 0) return false; // 注册时间为0 → 放行
|
||||
|
||||
long elapsed = System.currentTimeMillis() - regTimeMs;
|
||||
boolean isNew = elapsed < 3600000;
|
||||
|
||||
@@ -679,18 +681,20 @@ public class ChatFilterManager {
|
||||
+ "[_.][\\p{L}]{2,}");
|
||||
private static final Pattern DOT_VARIANTS =
|
||||
Pattern.compile(
|
||||
"[\uff0e\u3002\u2025\u2026\u00b7\uff65\ufe52]");
|
||||
"[.。‥…·・﹒]");
|
||||
/** 零宽/不可见字符:变形URL归一前剔除(如 baidu.<零宽空格>com) */
|
||||
private static final Pattern INVISIBLE =
|
||||
Pattern.compile(
|
||||
"[\u200b\u200c\u200d\u2060\ufeff\u00ad]");
|
||||
"[" + UnicodeChars.INVISIBLE_CHARS + "]");
|
||||
/** 点周带空白的变形域名:baidu . com(2026-10-08 加强拦截) */
|
||||
private static final Pattern SPACED_DOT_URL =
|
||||
Pattern.compile(
|
||||
"(?i)(?:[\\p{L}0-9](?:[\\p{L}0-9\\-]*[\\p{L}0-9])?[\\s\\u3000]*\\.)+[\\s\\u3000]*[\\p{L}]{2,}");
|
||||
"(?i)(?:[\\p{L}0-9](?:[\\p{L}0-9\\-]*[\\p{L}0-9])?[\\s"
|
||||
+ UnicodeChars.IDEOGRAPHIC_SPACE_TEXT + "]*\\.)+[\\s"
|
||||
+ UnicodeChars.IDEOGRAPHIC_SPACE_TEXT + "]*[\\p{L}]{2,}");
|
||||
private static final Pattern COLOR_CODES =
|
||||
Pattern.compile(
|
||||
"\u00a7[0-9a-fk-orA-FK-OR]");
|
||||
"§[0-9a-fk-orA-FK-OR]");
|
||||
|
||||
private static final Set<String> FILE_EXT;
|
||||
static {
|
||||
@@ -745,7 +749,7 @@ public class ChatFilterManager {
|
||||
String section = "";
|
||||
String line;
|
||||
while ((line = r.readLine()) != null) {
|
||||
if (line.startsWith("\uFEFF"))
|
||||
if (line.startsWith(UnicodeChars.BOM_TEXT))
|
||||
line = line.substring(1);
|
||||
String t = line.trim();
|
||||
|
||||
@@ -1036,7 +1040,19 @@ public class ChatFilterManager {
|
||||
"[\\s.,;!?,。;!?、)》」』】"
|
||||
+ "\\]\\[\\((]+$",
|
||||
"");
|
||||
if (!url.isEmpty()) urls.add(url);
|
||||
if (url.isEmpty()) continue;
|
||||
// ★ 28批修复(用户口径「接入illegal_domains.txt,命中的中英文后缀一律拦截」):
|
||||
// 显式协议URL(含 ://,如 http://1.2.3.4/x、https://a.com)无条件收录;
|
||||
// 裸域名/下划线 token 必须命中非法域名后缀(含中文后缀:中国/网络/公司…
|
||||
// 与英文后缀 com/cn/net…,均来自 illegal_domains.txt)才收录。
|
||||
// —— 修「今天.天气真好啊」被 URL_PATTERN 点分隔支整句匹配为域名而误伤。
|
||||
if (!url.contains("://")) {
|
||||
String probe = url;
|
||||
int slash = probe.indexOf('/');
|
||||
if (slash > 0) probe = probe.substring(0, slash);
|
||||
if (!containsIllegalDomain(probe)) continue;
|
||||
}
|
||||
if (!urls.contains(url)) urls.add(url);
|
||||
}
|
||||
// ★ 变形URL加强(2026-10-08):点周带空白(baidu . com)压缩空白后再判定;
|
||||
// 仅当顶级域命中「非法域名后缀」才收录,避免误伤 "OK. Good" 之类正常英文句子
|
||||
@@ -1044,8 +1060,8 @@ public class ChatFilterManager {
|
||||
while (m2.find()) {
|
||||
String token = m2.group();
|
||||
if (token.indexOf(' ') < 0 && token.indexOf('\t') < 0
|
||||
&& token.indexOf('\u3000') < 0) continue; // 无空白 = 常规域名,上面已提取
|
||||
String compact = token.replaceAll("[\\s\u3000]+", "").toLowerCase();
|
||||
&& token.indexOf(UnicodeChars.IDEOGRAPHIC_SPACE) < 0) continue; // 无空白 = 常规域名,上面已提取
|
||||
String compact = token.replaceAll("[\\s" + UnicodeChars.IDEOGRAPHIC_SPACE + "]+", "").toLowerCase();
|
||||
int dot = compact.lastIndexOf('.');
|
||||
if (dot < 0) continue;
|
||||
if (!illegalDomainSuffixes.contains(compact.substring(dot + 1))) continue;
|
||||
@@ -1088,17 +1104,126 @@ public class ChatFilterManager {
|
||||
|
||||
/**
|
||||
* 判断URL是否应被拦截
|
||||
* 下划线分隔的字符串(如 player_name),必须命中非法域名后缀才拦截
|
||||
* 点分隔或标准URL正常拦截
|
||||
* ★ 28批修复(用户口径):接入 illegal_domains.txt,命中的中英文后缀一律拦截,其余放行。
|
||||
* - 白名单优先放行;
|
||||
* - 显式协议URL(含 ://,如 http://a.com、http://1.2.3.4/x)非白名单一律拦截
|
||||
* (IP直链无后缀可查,保持反广告强度);
|
||||
* - 裸域名/下划线 token(如 xxx.com、xxx_com、今天.天气真好啊):
|
||||
* 必须命中非法域名后缀(英文 com/cn/net… 或中文 中国/网络/公司…)才拦截 —— 修误伤。
|
||||
*/
|
||||
public boolean shouldBlockUrl(String url) {
|
||||
if (isWhitelisted(url)) return false;
|
||||
// 下划线分隔 → 检查是否非法域名,不在列表则跳过(合法玩家名下划线误匹配)
|
||||
if (url.contains("_")) {
|
||||
return containsIllegalDomain(url);
|
||||
// 显式协议URL → 非白名单一律拦截(原行为保持)
|
||||
if (url.contains("://")) {
|
||||
return true;
|
||||
}
|
||||
// 点分隔或标准URL正常拦截
|
||||
return true;
|
||||
// 裸域名/下划线token → 必须命中非法域名后缀才拦截
|
||||
String probe = url;
|
||||
int slash = probe.indexOf('/');
|
||||
if (slash > 0) probe = probe.substring(0, slash);
|
||||
return containsIllegalDomain(probe);
|
||||
}
|
||||
|
||||
// ==================== 35批修订:IPv4/IPv6 地址检测(加白豁免,其余无条件拦) ====================
|
||||
|
||||
/** IPv4:四段 0-255,前后用非数字边界包住("1.21.4" 三段版本号不匹配,"1.2.3.4.5" 不匹配) */
|
||||
private static final Pattern IPV4_PATTERN = Pattern.compile(
|
||||
"(?<![0-9.])(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)"
|
||||
+ "(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}(?![0-9]|\\.\\d)");
|
||||
|
||||
/** IPv6 候选:>=1 个冒号的十六进制/冒号/点串(粗取后 isRealIpv6 严格校验,防 12:34:56 时间误伤) */
|
||||
private static final Pattern IPV6_CAND = Pattern.compile(
|
||||
"(?i)(?:[0-9a-f]{0,4}:){1,}[0-9a-f:.]*");
|
||||
|
||||
/**
|
||||
* 提取消息里全部 IPv4/IPv6 地址(严格校验、按出现顺序去重)。
|
||||
* IPv4 按 0-255 校验;IPv6 先粗取候选再去尾点后校验组数/::唯一性/组格式,
|
||||
* 时间 "12:34:56"、三段冒号串不会误伤;句末句号("…cbd5.")不吞。
|
||||
*/
|
||||
public java.util.List<String> extractIpAddresses(String msg) {
|
||||
java.util.List<String> out = new ArrayList<>();
|
||||
if (msg == null || msg.isEmpty()) return out;
|
||||
Matcher m4 = IPV4_PATTERN.matcher(msg);
|
||||
while (m4.find()) {
|
||||
String ip = m4.group();
|
||||
if (!out.contains(ip)) out.add(ip);
|
||||
}
|
||||
Matcher m6 = IPV6_CAND.matcher(msg);
|
||||
while (m6.find()) {
|
||||
String cand = m6.group();
|
||||
// 去尾部句点(英文句号紧跟 IP 时不能因此漏判)
|
||||
int end = cand.length();
|
||||
while (end > 0 && cand.charAt(end - 1) == '.') end--;
|
||||
cand = cand.substring(0, end);
|
||||
if (cand.isEmpty() || !isRealIpv6(cand)) continue;
|
||||
if (!out.contains(cand)) out.add(cand);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息是否包含「未加白」的 IPv4/IPv6(35批修订口径,用户原话:
|
||||
* 「对加白的ip和玩家不受影响,其它玩家ipv6、ipv4全部无条件拦下来」)。
|
||||
* 加白 IP = chat.txt「白名单」节条目(与域名共用同一列表,支持完整 IP 与 *.x.x.x 后缀);
|
||||
* 消息里的 IP 全部加白才放行,出现任何一个未加白 IP 即返回 true。
|
||||
* 加白玩家在 onChat 更早的「白名单玩家」早退里豁免,走不到 IP 检测。
|
||||
*/
|
||||
public boolean containsNonWhitelistedIp(String msg) {
|
||||
for (String ip : extractIpAddresses(msg)) {
|
||||
if (!isWhitelistedIp(ip)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加白 IP 判定。不复用 isWhitelisted:它按第一个冒号剥「端口」,
|
||||
* 对 IPv6(2408:8352::1)会截断成 "2408" 误判未加白 —— 这里不剥端口,
|
||||
* 提取出来的 IP 本就不含端口;条目支持完整等值与 *.段 后缀通配。
|
||||
*/
|
||||
private boolean isWhitelistedIp(String ip) {
|
||||
if (ip == null || ip.isEmpty()) return false;
|
||||
String clean = ip.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
for (String entry : whitelistUrls) {
|
||||
String e = entry.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (e.isEmpty()) continue;
|
||||
if (e.startsWith("*.")) {
|
||||
if (clean.endsWith(e.substring(1))) return true;
|
||||
} else if (clean.equals(e)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 候选串是否为合法 IPv6(:: 压缩、末段 IPv4-mapped、%scope 尾巴) */
|
||||
private static boolean isRealIpv6(String t) {
|
||||
if (t == null || t.isEmpty()) return false;
|
||||
String s = t;
|
||||
int pct = s.indexOf('%');
|
||||
if (pct >= 0) s = s.substring(0, pct); // fe80::1%eth0
|
||||
int slash = s.indexOf('/');
|
||||
if (slash >= 0) s = s.substring(0, slash); // CIDR 尾巴
|
||||
if (s.indexOf(':') < 0) return false;
|
||||
int dc = s.indexOf("::");
|
||||
if (dc >= 0 && s.indexOf("::", dc + 1) >= 0) return false; // 两个 :: 非法
|
||||
String body = dc >= 0
|
||||
? s.substring(0, dc) + ":" + s.substring(dc + 2) : s;
|
||||
int groups = 0;
|
||||
for (String part : body.split(":", -1)) {
|
||||
if (part.isEmpty()) continue;
|
||||
if (part.indexOf('.') >= 0) {
|
||||
// 末段 IPv4 形态(::ffff:192.168.1.1)→ 按 IPv4 校验,计 2 组
|
||||
if (!part.matches("(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)"
|
||||
+ "(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}")) return false;
|
||||
groups += 2;
|
||||
} else {
|
||||
if (!part.matches("[0-9a-fA-F]{1,4}")) return false;
|
||||
groups++;
|
||||
}
|
||||
if (groups > 8) return false;
|
||||
}
|
||||
if (dc >= 0) return groups < 8; // :: 至少压缩 1 组 -> 显式组 <=7
|
||||
return groups == 8; // 无 :: 必须恰好 8 组
|
||||
}
|
||||
|
||||
public boolean isLikelyDomain(String url) {
|
||||
|
||||
@@ -15,6 +15,7 @@ public class ChatInputManager {
|
||||
SET_EMAIL,
|
||||
SET_EMAIL_VERIFY,
|
||||
ADMIN_AUTH,
|
||||
SH_ADMIN_AUTH, // ★ 问题2:二手管理面板密码(ShopManager.onPublishChat 消费)
|
||||
ADMIN_SET_POINTS,
|
||||
ADMIN_SET_PWD,
|
||||
ADMIN_DELETE_CONFIRM,
|
||||
@@ -639,6 +640,18 @@ public class ChatInputManager {
|
||||
reset(p);
|
||||
return;
|
||||
}
|
||||
// ★ 第八批(2026-10-12):邀请码一次性——已绑定过邀请人的
|
||||
// 禁止重复使用(invited_by 持久落库,重启后依然拦截)
|
||||
Object bound = db.getField(
|
||||
myName, "invited_by");
|
||||
if (bound != null
|
||||
&& !bound.toString().isEmpty()) {
|
||||
p.sendMessage(
|
||||
"§c你已绑定过邀请人(" + bound
|
||||
+ "),邀请码仅能使用一次");
|
||||
reset(p);
|
||||
return;
|
||||
}
|
||||
boolean found = false;
|
||||
for (Map<String, Object> u :
|
||||
db.getAllUsers()) {
|
||||
@@ -650,17 +663,52 @@ public class ChatInputManager {
|
||||
u.get("player_name");
|
||||
db.setField(myName,
|
||||
"invited_by", inv);
|
||||
db.addPoints(inv, 50);
|
||||
p.sendMessage(
|
||||
"§a邀请成功!邀请人: "
|
||||
+ inv);
|
||||
// ★ 第九批:绑定时间落库(供邀请GUI显示
|
||||
// 「邀请成功时间+邀请人」)
|
||||
db.setField(myName, "invited_at",
|
||||
System.currentTimeMillis());
|
||||
// ★ 第九批(配置懒更新):玩家绑定邀请码时
|
||||
// 自动重载 规则.md,实时刷新奖励信息
|
||||
// (不再依赖启动加载或定时热重载)
|
||||
mainPlugin.getCommission().loadRules();
|
||||
// ★ 第九批:绑定即计入邀请人「本月邀请」
|
||||
// (修复旧逻辑在签到才计数导致恒 0 人)
|
||||
mainPlugin.getInvite().onInviteeBind(
|
||||
inv, myName);
|
||||
// ★ 第八批(2026-10-12):绑定奖励改按
|
||||
// 邀请好友赢债券/规则.md 发债券;
|
||||
// 旧 addPoints(inv,50) 积分随提成规则体系废弃
|
||||
// ★ 第九批:奖励单向改双向——邀请人 + 被邀请人
|
||||
int bondGive = mainPlugin
|
||||
.getCommission()
|
||||
.payInviteBond(inv, myName);
|
||||
int bondGet = mainPlugin
|
||||
.getCommission()
|
||||
.payInviteeBond(myName, inv);
|
||||
String bMsg =
|
||||
"§a邀请成功!邀请人: " + inv;
|
||||
if (bondGet > 0) {
|
||||
bMsg += "§7,你获得 §e" + bondGet
|
||||
+ " §a债券";
|
||||
}
|
||||
p.sendMessage(bMsg);
|
||||
Player ip =
|
||||
Bukkit.getPlayer(inv);
|
||||
Bukkit.getPlayerExact(inv);
|
||||
if (ip != null
|
||||
&& ip.isOnline())
|
||||
&& ip.isOnline()) {
|
||||
String extra = "";
|
||||
if (bondGive > 0) {
|
||||
extra = "§7,你获得 §e"
|
||||
+ bondGive
|
||||
+ " §a债券";
|
||||
} else if (bondGive == 0) {
|
||||
extra = "§7(今日邀请债券已达上限)";
|
||||
}
|
||||
ip.sendMessage("§a"
|
||||
+ myName
|
||||
+ " 使用了您的邀请码");
|
||||
+ " 使用了您的邀请码"
|
||||
+ extra);
|
||||
}
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,44 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 邀请好友赢债券奖励(配置:邀请好友赢债券/规则.md)。
|
||||
*
|
||||
* <p>★ 2026-10-12 第八批(用户决策):旧「提成规则.txt」体系整体废弃,
|
||||
* 奖励改由新 md 规则控制,绑定邀请码成功时立即结算债券。</p>
|
||||
*
|
||||
* <p>★ 2026-10-12 第九批(用户需求)三点改造:
|
||||
* ① 解析键改用新 md 4 键(旧「单次邀请奖励上限」→「邀请人单次奖励」),
|
||||
* 新增「被邀请人单次奖励」「单日邀请翻倍额度」;
|
||||
* ② 奖励单向改<b>双向</b>——payInviteBond 给邀请人、payInviteeBond 给被邀请人;
|
||||
* 翻倍额度作用于邀请人(每邀请 X 人,奖金翻 Y 倍);
|
||||
* ③ 支持<b>懒更新</b>——玩家绑定邀请码时调用 loadRules() 实时重载规则,
|
||||
* 不再依赖启动时一次性加载或定时热重载。</p>
|
||||
*/
|
||||
public class CommissionManager {
|
||||
|
||||
private final Main main;
|
||||
private final Logger logger;
|
||||
private final List<double[]> rules =
|
||||
new ArrayList<>();
|
||||
|
||||
/** 邀请人单次奖励(债券数,绑定成功立即结算) */
|
||||
private volatile int perInviteReward = 10;
|
||||
/** 被邀请人单次奖励(债券数,绑定成功立即结算) */
|
||||
private volatile int perInviteeReward = 5;
|
||||
/** 单日邀请奖励上限(债券数,自然日重置,仅针对邀请人) */
|
||||
private volatile int dailyInviteCap = 100;
|
||||
/** 单日邀请翻倍额度:每邀请 X 人(>0 生效,0=关闭翻倍) */
|
||||
private volatile int doubleEveryX = 0;
|
||||
/** 单日邀请翻倍额度:奖金翻 Y 倍(翻 Y 倍 = 每满 X 人基数加 Y 倍) */
|
||||
private volatile int doubleByY = 0;
|
||||
|
||||
public CommissionManager(Main main) {
|
||||
this.main = main;
|
||||
@@ -20,190 +46,259 @@ public class CommissionManager {
|
||||
loadRules();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重载规则(★ 第九批:绑定邀请码时懒调用,实时刷新奖励信息)。
|
||||
*/
|
||||
public void loadRules() {
|
||||
rules.clear();
|
||||
File f = new File(
|
||||
main.getDataFolder(),
|
||||
// ★ 代码层面删除旧提成规则.txt(手删会被再次释放,故写文件
|
||||
// 逻辑已移除,这里负责清理存量文件)
|
||||
File legacy = new File(main.getDataFolder(),
|
||||
"提成规则.txt");
|
||||
if (!f.exists()) {
|
||||
writeDefault(f);
|
||||
if (legacy.exists() && legacy.delete()) {
|
||||
logger.info("[Commission] 已删除旧版 提成规则.txt"
|
||||
+ "(改由 邀请好友赢债券/规则.md 控制)");
|
||||
}
|
||||
try (BufferedReader br =
|
||||
new BufferedReader(
|
||||
new InputStreamReader(
|
||||
new FileInputStream(f),
|
||||
StandardCharsets
|
||||
.UTF_8))) {
|
||||
|
||||
File f = new File(main.getDataFolder(),
|
||||
"邀请好友赢债券/规则.md");
|
||||
if (!f.exists()) {
|
||||
try {
|
||||
main.saveResource(
|
||||
"邀请好友赢债券/规则.md", false);
|
||||
} catch (Exception e) {
|
||||
logger.warning("[Commission] 规则文件释放失败: "
|
||||
+ e.getMessage());
|
||||
}
|
||||
}
|
||||
if (!f.exists()) {
|
||||
logger.warning("[Commission] 规则文件缺失,使用默认值"
|
||||
+ " 邀请人=" + perInviteReward
|
||||
+ " 被邀请人=" + perInviteeReward
|
||||
+ " 单日=" + dailyInviteCap);
|
||||
return;
|
||||
}
|
||||
// ★ 安全交换(2026-10-13):jar 模板多出的配置行补进原文件,
|
||||
// 说明文本与用户改过的值原样保留(旧菜新菜一起端上桌)
|
||||
java.util.List<String> inviteTpl =
|
||||
ConfigManager.readJarResourceLines(
|
||||
main.getResource("邀请好友赢债券/规则.md"));
|
||||
if (inviteTpl != null) {
|
||||
main.getConfig2().safeUpgradeDoc(f, inviteTpl, "邀请债券规则");
|
||||
}
|
||||
boolean inCfg = false;
|
||||
try (BufferedReader br = new BufferedReader(
|
||||
new InputStreamReader(
|
||||
new FileInputStream(f),
|
||||
StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
line = line.trim();
|
||||
if (line.isEmpty()
|
||||
|| line.startsWith("#"))
|
||||
String t = line.trim();
|
||||
if (t.startsWith("##") && t.contains("配置区")
|
||||
&& t.contains("开始")) {
|
||||
inCfg = true;
|
||||
continue;
|
||||
// 格式: 邀请人数:提成百分比
|
||||
// 如 "5:15" = 邀请5人提成15%
|
||||
String[] parts =
|
||||
line.split(":");
|
||||
if (parts.length >= 2) {
|
||||
try {
|
||||
int count = Integer.parseInt(
|
||||
parts[0].trim());
|
||||
double rate = Double
|
||||
.parseDouble(
|
||||
parts[1]
|
||||
.trim()) / 100.0;
|
||||
rules.add(new double[]{
|
||||
count, rate});
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
if (t.startsWith("##") && t.contains("配置区")
|
||||
&& t.contains("结束")) {
|
||||
break;
|
||||
}
|
||||
if (!inCfg || !t.startsWith("|")) continue;
|
||||
String[] cols = t.split("\\|", -1);
|
||||
if (cols.length < 4) continue; // | 键 | 解释 | 值 |
|
||||
String key = cols[1].trim();
|
||||
String val = cols[cols.length - 2].trim()
|
||||
.replace("`", "");
|
||||
// ★ 第九批:适配新 md 4 键
|
||||
if ("邀请人单次奖励".equals(key)) {
|
||||
perInviteReward = parseIntSafe(val,
|
||||
perInviteReward);
|
||||
} else if ("被邀请人单次奖励".equals(key)) {
|
||||
perInviteeReward = parseIntSafe(val,
|
||||
perInviteeReward);
|
||||
} else if ("单日邀请奖励上限".equals(key)) {
|
||||
dailyInviteCap = parseIntSafe(val,
|
||||
dailyInviteCap);
|
||||
} else if ("单日邀请翻倍额度".equals(key)) {
|
||||
parseDoubleRule(val);
|
||||
}
|
||||
}
|
||||
// 按人数排序(从大到小)
|
||||
rules.sort((a, b) ->
|
||||
Double.compare(b[0], a[0]));
|
||||
logger.info(
|
||||
"[Commission] 加载 "
|
||||
+ rules.size() + " 条提成规则");
|
||||
logger.info("[Commission] 邀请债券规则: 邀请人="
|
||||
+ perInviteReward
|
||||
+ " 被邀请人=" + perInviteeReward
|
||||
+ " 单日=" + dailyInviteCap
|
||||
+ " 翻倍=" + (doubleEveryX > 0
|
||||
? ("每" + doubleEveryX + "人翻"
|
||||
+ doubleByY + "倍")
|
||||
: "关闭"));
|
||||
} catch (Exception e) {
|
||||
logger.warning(
|
||||
"[Commission] 加载失败: "
|
||||
+ e.getMessage());
|
||||
logger.warning("[Commission] 规则解析失败: "
|
||||
+ e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void writeDefault(File f) {
|
||||
try (PrintWriter pw = new PrintWriter(
|
||||
new OutputStreamWriter(
|
||||
new FileOutputStream(f),
|
||||
StandardCharsets.UTF_8))) {
|
||||
pw.println("# 邀请提成规则");
|
||||
pw.println("# 格式: 邀请人数:提成百分比");
|
||||
pw.println("# 邀请10人提成20%");
|
||||
pw.println("10:20");
|
||||
pw.println("# 邀请5人提成15%");
|
||||
pw.println("5:15");
|
||||
pw.println("# 邀请1人提成10%");
|
||||
pw.println("1:10");
|
||||
} catch (IOException ignored) {
|
||||
/** 解析「每邀请X人,奖金翻Y倍」格式 */
|
||||
private void parseDoubleRule(String v) {
|
||||
// 默认关闭
|
||||
doubleEveryX = 0;
|
||||
doubleByY = 0;
|
||||
if (v == null) return;
|
||||
Matcher m = Pattern.compile(
|
||||
"每邀请\\s*(\\d+)\\s*人\\s*[,,]\\s*奖金翻\\s*(\\d+)\\s*倍")
|
||||
.matcher(v);
|
||||
if (m.find()) {
|
||||
doubleEveryX = parseIntSafe(m.group(1), 0);
|
||||
doubleByY = parseIntSafe(m.group(2), 0);
|
||||
} else {
|
||||
logger.warning("[Commission] 翻倍额度格式无法解析: "
|
||||
+ v + "(示例:每邀请2人,奖金翻1倍)");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取邀请人的提成比例
|
||||
* 根据已邀请人数匹配最高规则
|
||||
*/
|
||||
public double getCommissionRate(
|
||||
String inviter) {
|
||||
int inviteCount = getInviteCount(
|
||||
inviter);
|
||||
for (double[] rule : rules) {
|
||||
if (inviteCount >= (int) rule[0]) {
|
||||
return rule[1];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取邀请人数
|
||||
*/
|
||||
public int getInviteCount(
|
||||
String inviter) {
|
||||
private static int parseIntSafe(String s, int def) {
|
||||
try {
|
||||
Object val =
|
||||
main.getDb().getField(
|
||||
inviter,
|
||||
"monthly_invite_count");
|
||||
if (val instanceof Number) {
|
||||
return ((Number) val).intValue();
|
||||
}
|
||||
Object total =
|
||||
main.getDb().getField(
|
||||
inviter,
|
||||
"total_invite_count");
|
||||
if (total instanceof Number) {
|
||||
return ((Number) total).intValue();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
return (int) Double.parseDouble(s.trim());
|
||||
} catch (Exception e) {
|
||||
return def;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 邀请人单次奖励(债券) */
|
||||
public int getPerInviteReward() {
|
||||
return perInviteReward;
|
||||
}
|
||||
|
||||
/** 被邀请人单次奖励(债券) */
|
||||
public int getPerInviteeReward() {
|
||||
return perInviteeReward;
|
||||
}
|
||||
|
||||
/** 单日邀请奖励上限(债券) */
|
||||
public int getDailyInviteCap() {
|
||||
return dailyInviteCap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发放提成
|
||||
* 被邀请人获得奖励时,邀请人获得提成
|
||||
* 邀请绑定成功 → 给<b>邀请人</b>发债券(★ 第九批:含翻倍额度)。
|
||||
* 规则见 邀请好友赢债券/规则.md:每次按单次上限结算(每满 X 人翻 Y 倍),
|
||||
* 当日累计不超单日上限。
|
||||
*
|
||||
* @param inviter 邀请人(收款方)
|
||||
* @param invitee 被邀请人(绑定者)
|
||||
* @return >0 实发债券数;0 = 今日已达上限或规则为0;
|
||||
* -1 = 异常/冻结未发
|
||||
*/
|
||||
public void payCommission(
|
||||
String invitee,
|
||||
double originalAmount) {
|
||||
// 查找邀请人
|
||||
String inviter = getInviter(invitee);
|
||||
if (inviter == null
|
||||
|| inviter.isEmpty()) return;
|
||||
|
||||
double rate = getCommissionRate(
|
||||
inviter);
|
||||
if (rate <= 0) return;
|
||||
|
||||
double commission =
|
||||
originalAmount * rate;
|
||||
if (commission <= 0) return;
|
||||
|
||||
// Economy已移除,佣金提成功能不可用
|
||||
// 邀请人可通过债券系统获取奖励
|
||||
|
||||
// 通知邀请人
|
||||
Player inviterP =
|
||||
main.getServer()
|
||||
.getPlayer(inviter);
|
||||
if (inviterP != null
|
||||
&& inviterP.isOnline()) {
|
||||
inviterP.sendMessage(
|
||||
"§a§l[邀请提成] §f"
|
||||
+ invitee + " 完成任务,"
|
||||
+ "你获得 §e"
|
||||
+ String.format("%.2f",
|
||||
commission)
|
||||
+ " §f游戏币提成 ("
|
||||
+ (int)(rate * 100) + "%)");
|
||||
public int payInviteBond(String inviter, String invitee) {
|
||||
if (inviter == null || inviter.isEmpty()) return -1;
|
||||
if (perInviteReward <= 0) return 0;
|
||||
int used = inviteBondToday(inviter);
|
||||
int room = dailyInviteCap - used;
|
||||
if (room <= 0) {
|
||||
logger.info("[Commission] " + inviter
|
||||
+ " 今日邀请债券已达上限(" + dailyInviteCap + ")");
|
||||
return 0;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"[Commission] 提成: "
|
||||
+ inviter + " ← "
|
||||
+ invitee + " | "
|
||||
+ String.format("%.2f",
|
||||
originalAmount)
|
||||
+ " × "
|
||||
+ (int)(rate * 100) + "% = "
|
||||
+ String.format("%.2f",
|
||||
commission));
|
||||
// ★ 第九批:单日邀请翻倍额度——每邀请 X 人,奖金翻 Y 倍
|
||||
// nToday = 今日已发放的邀请奖励笔数(≈已成功邀请人数)
|
||||
// mult = 1 + (nToday / X) * Y(整数除法,每满 X 人加 Y 倍)
|
||||
int nToday = inviteBondCountToday(inviter);
|
||||
int mult = 1;
|
||||
if (doubleEveryX > 0) {
|
||||
mult = 1 + (nToday / doubleEveryX) * doubleByY;
|
||||
if (mult < 1) mult = 1;
|
||||
}
|
||||
int base = perInviteReward * mult;
|
||||
int give = Math.min(base, room);
|
||||
int after = main.getBonds().addBonds(inviter, give,
|
||||
"invite_reward", invitee, "system",
|
||||
"邀请好友绑定奖励(" + invitee + ")");
|
||||
if (after < 0) return -1; // 冻结期未入账
|
||||
logger.info("[Commission] 邀请债券: " + inviter + " ← "
|
||||
+ invitee + " | +" + give + "(基数"
|
||||
+ perInviteReward + "×" + mult + ",今日累计 "
|
||||
+ (used + give) + "/" + dailyInviteCap + ")");
|
||||
return give;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取邀请人
|
||||
* 邀请绑定成功 → 给<b>被邀请人</b>发债券(★ 第九批:双向奖励)。
|
||||
* 被邀请人奖励为固定单次数额,不受单日上限约束(上限仅针对邀请人)。
|
||||
*
|
||||
* @param invitee 被邀请人(绑定者,收款方)
|
||||
* @param inviter 邀请人(被邀请人的邀请来源)
|
||||
* @return >0 实发债券数;0 = 规则为0;-1 = 异常/冻结未发
|
||||
*/
|
||||
private String getInviter(String player) {
|
||||
public int payInviteeBond(String invitee, String inviter) {
|
||||
if (invitee == null || invitee.isEmpty()) return -1;
|
||||
if (perInviteeReward <= 0) return 0;
|
||||
// 用独立 type='invitee_reward',避免污染邀请人日上限统计
|
||||
int after = main.getBonds().addBonds(invitee,
|
||||
perInviteeReward, "invitee_reward", inviter,
|
||||
"system",
|
||||
"被邀请人绑定奖励(邀请人:" + inviter + ")");
|
||||
if (after < 0) return -1; // 冻结期未入账
|
||||
logger.info("[Commission] 被邀请人债券: " + invitee
|
||||
+ " ← " + inviter + " | +" + perInviteeReward);
|
||||
return perInviteeReward;
|
||||
}
|
||||
|
||||
/** 今日 0 点起该玩家已发放的邀请债券总数(金额) */
|
||||
private int inviteBondToday(String inviter) {
|
||||
long dayStart = LocalDate.now()
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant().toEpochMilli();
|
||||
try {
|
||||
Object val =
|
||||
main.getDb().getField(
|
||||
player,
|
||||
"invited_by");
|
||||
if (val != null
|
||||
&& !val.toString()
|
||||
.isEmpty()) {
|
||||
return val.toString();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
PreparedStatement ps = main.getBonds()
|
||||
.getConnection().prepareStatement(
|
||||
"SELECT COALESCE(SUM(amount),0) "
|
||||
+ "FROM bond_transaction "
|
||||
+ "WHERE player_name=? "
|
||||
+ "AND type='invite_reward' "
|
||||
+ "AND time>=?");
|
||||
ps.setString(1, inviter);
|
||||
ps.setLong(2, dayStart);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
int v = rs.next() ? rs.getInt(1) : 0;
|
||||
rs.close();
|
||||
ps.close();
|
||||
return v;
|
||||
} catch (Exception e) {
|
||||
logger.warning("[Commission] 查询当日邀请债券失败: "
|
||||
+ e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 今日 0 点起该玩家已成功邀请人数(发奖笔数,用于翻倍档位) */
|
||||
private int inviteBondCountToday(String inviter) {
|
||||
long dayStart = LocalDate.now()
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant().toEpochMilli();
|
||||
try {
|
||||
PreparedStatement ps = main.getBonds()
|
||||
.getConnection().prepareStatement(
|
||||
"SELECT COUNT(*) "
|
||||
+ "FROM bond_transaction "
|
||||
+ "WHERE player_name=? "
|
||||
+ "AND type='invite_reward' "
|
||||
+ "AND time>=?");
|
||||
ps.setString(1, inviter);
|
||||
ps.setLong(2, dayStart);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
int v = rs.next() ? rs.getInt(1) : 0;
|
||||
rs.close();
|
||||
ps.close();
|
||||
return v;
|
||||
} catch (Exception e) {
|
||||
logger.warning("[Commission] 查询当日邀请人数失败: "
|
||||
+ e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断金额是否包含经济内容
|
||||
*/
|
||||
public boolean hasEconomyContent(
|
||||
String text) {
|
||||
public boolean hasEconomyContent(String text) {
|
||||
String t = text.toLowerCase();
|
||||
return t.contains("游戏币")
|
||||
|| t.contains("金币")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,11 @@ public class DatabaseManager {
|
||||
+ dbFile.getAbsolutePath());
|
||||
Statement st = db.createStatement();
|
||||
st.execute("PRAGMA journal_mode=WAL");
|
||||
// ★ 二十二批:busy_timeout —— WAL 下读写仍会与
|
||||
// db-worker/长事务争锁,无超时会让主线程
|
||||
// sqlite3_step 无限等待(watchdog 卡点之一),
|
||||
// 5秒等锁后报 SQLITE_BUSY 快速失败。
|
||||
st.execute("PRAGMA busy_timeout=5000");
|
||||
|
||||
// 用户表
|
||||
st.execute("CREATE TABLE IF NOT EXISTS "
|
||||
@@ -51,6 +56,7 @@ public class DatabaseManager {
|
||||
+ "gift_claimed TEXT DEFAULT '',"
|
||||
+ "invite_code TEXT DEFAULT '',"
|
||||
+ "invited_by TEXT DEFAULT '',"
|
||||
+ "invited_at INTEGER DEFAULT 0,"
|
||||
+ "monthly_invite_count INTEGER DEFAULT 0,"
|
||||
+ "invite_month TEXT DEFAULT '',"
|
||||
+ "points INTEGER DEFAULT 0,"
|
||||
@@ -92,6 +98,10 @@ public class DatabaseManager {
|
||||
"TEXT DEFAULT ''");
|
||||
safeAdd(st, "last_login_ip",
|
||||
"TEXT DEFAULT ''");
|
||||
// ★ 第九批:邀请绑定时间(毫秒时间戳),
|
||||
// 供「我的邀请码」GUI 显示邀请成功时间
|
||||
safeAdd(st, "invited_at",
|
||||
"INTEGER DEFAULT 0");
|
||||
// UI偏好:1=CLI(默认), 0=GUI
|
||||
safeAdd(st, "ui_mode",
|
||||
"INTEGER DEFAULT 1");
|
||||
@@ -391,7 +401,7 @@ public class DatabaseManager {
|
||||
+ "time INTEGER"
|
||||
+ ")");
|
||||
|
||||
// 背包自动滚动备份表(15分钟定时):每位玩家保留最近 3 份
|
||||
// 背包自动滚动备份表(每小时 0/15/30/45 分准点定时):每位玩家保留最近 3 份
|
||||
st.execute("CREATE TABLE IF NOT EXISTS "
|
||||
+ "inv_backup_auto ("
|
||||
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
@@ -711,6 +721,29 @@ public class DatabaseManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ OP白名单授权校验用(2026-10-11):与 userExistsIgnoreCase 相反的失败语义——
|
||||
* SQL 异常一律返回 false(保守拒绝)。opwl add/addtime 宁可让运维改 json 兜底,
|
||||
* 也绝不因 DB 抖动把临时 OP 发给未注册玩家。
|
||||
*/
|
||||
public boolean userExistsStrict(String name) {
|
||||
if (name == null || name.isEmpty()) return false;
|
||||
try {
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"SELECT 1 FROM users "
|
||||
+ "WHERE player_name=? "
|
||||
+ "COLLATE NOCASE");
|
||||
ps.setString(1, name);
|
||||
boolean r = ps.executeQuery().next();
|
||||
ps.close();
|
||||
return r;
|
||||
} catch (SQLException e) {
|
||||
logger.warning("[OP白名单] 注册校验查询失败(拒绝): "
|
||||
+ e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void createUser(String name, String hash,
|
||||
String salt) {
|
||||
try {
|
||||
@@ -2138,6 +2171,44 @@ public class DatabaseManager {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取玩家最新一份自动背包备份(黑名单解除时返还用)。
|
||||
* @return String[]{contents_b64, armor_b64, extra_b64, level, experience},无备份返回 null
|
||||
*/
|
||||
public String[] getLatestAutoBackup(String playerName) {
|
||||
try {
|
||||
PreparedStatement ps =
|
||||
db.prepareStatement(
|
||||
"SELECT contents_b64, "
|
||||
+ "armor_b64, "
|
||||
+ "extra_b64, "
|
||||
+ "level, "
|
||||
+ "experience "
|
||||
+ "FROM inv_backup_auto "
|
||||
+ "WHERE player_name=? "
|
||||
+ "ORDER BY save_time DESC "
|
||||
+ "LIMIT 1");
|
||||
ps.setString(1, playerName);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
if (rs.next()) {
|
||||
String[] result = new String[5];
|
||||
result[0] = rs.getString("contents_b64");
|
||||
result[1] = rs.getString("armor_b64");
|
||||
result[2] = rs.getString("extra_b64");
|
||||
result[3] = String.valueOf(rs.getInt("level"));
|
||||
result[4] = String.valueOf(rs.getDouble("experience"));
|
||||
rs.close();
|
||||
ps.close();
|
||||
return result;
|
||||
}
|
||||
rs.close();
|
||||
ps.close();
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取玩家最新背包备份
|
||||
*/
|
||||
@@ -2981,7 +3052,7 @@ public class DatabaseManager {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// ==================== 背包自动滚动备份(15分钟定时)====================
|
||||
// ==================== 背包自动滚动备份(每小时 0/15/30/45 分准点定时)====================
|
||||
|
||||
/**
|
||||
* 保存背包自动备份 —— 追加写入,每位玩家仅保留最近 3 份。
|
||||
|
||||
@@ -181,7 +181,7 @@ public class DurationParser {
|
||||
|
||||
/** 中文日期+时间:2026年10月8日 14:43(“日”已界定日期边界,时间可紧贴) */
|
||||
private static final Pattern DT_CN = Pattern.compile(
|
||||
"(\\d{4})年(\\d{1,2})月(\\d{1,2})日(?:[ T\\u3000]+)?"
|
||||
"(\\d{4})年(\\d{1,2})月(\\d{1,2})日(?:[ T" + UnicodeChars.IDEOGRAPHIC_SPACE_TEXT + "]+)?"
|
||||
+ "(\\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
|
||||
@@ -189,7 +189,7 @@ public class DurationParser {
|
||||
* 防止 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])");
|
||||
+ "[ T" + UnicodeChars.IDEOGRAPHIC_SPACE_TEXT + "]+(\\d{1,2}):(\\d{2})(?::(\\d{2}))?(?!\\d|[A-Za-z])");
|
||||
|
||||
/** 两位年份无时间:26-10-08
|
||||
* lookbehind 防止吃掉 4 位年份的后半段(2026-07-09 不能被读成 07-09) */
|
||||
@@ -315,6 +315,15 @@ public class DurationParser {
|
||||
return section + (hasNum ? num : 0);
|
||||
}
|
||||
|
||||
/** ★ 第十一批(2026-10-12):秒/毫秒混合时间戳归一化为毫秒。
|
||||
* login.db 的 register_time/last_login_time/invited_at 两种单位
|
||||
* 混存(Java 本地注册写毫秒、PHP 同步拉回写秒),凡显示、比较、
|
||||
* 回填前一律先过这里。幂等:已是毫秒的原样返回。
|
||||
* 阈值 10^12:秒级时间戳到 33658 年才到 10^12,安全。 */
|
||||
public static long toMs(long ts) {
|
||||
return (ts > 0 && ts < 1000000000000L) ? ts * 1000L : ts;
|
||||
}
|
||||
|
||||
/** 格式化绝对到期时间戳为「yyyy年M月d日 HH:mm」 */
|
||||
public static String formatExpire(long ms) {
|
||||
Calendar c = Calendar.getInstance();
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.bukkit.event.inventory.*;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.inventory.meta.SkullMeta;
|
||||
import org.bukkit.scheduler.BukkitRunnable;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
@@ -43,6 +44,8 @@ public class GUIManager implements Listener {
|
||||
"§6§l管理员面板";
|
||||
public static final String T_INVITE =
|
||||
"§6§l邀请数据";
|
||||
public static final String T_MONTHLY_INVITE =
|
||||
"§6§l本月邀请";
|
||||
public static final String T_USER_MGMT =
|
||||
"§6§l用户管理";
|
||||
public static final String T_USER_GROUP =
|
||||
@@ -396,7 +399,7 @@ public class GUIManager implements Listener {
|
||||
public void openSubMenu(
|
||||
Player p, String fileName) {
|
||||
// ★ 剥离MC颜色代码(§f、§7等),防止污染文件名
|
||||
fileName = fileName.replaceAll("\u00A7[0-9a-fk-or]", "").trim();
|
||||
fileName = fileName.replaceAll("§[0-9a-fk-or]", "").trim();
|
||||
String menuTitle =
|
||||
"§6§l" + fileName
|
||||
.replace(".txt", "");
|
||||
@@ -629,7 +632,7 @@ public class GUIManager implements Listener {
|
||||
for (String line : block.split("\n")) {
|
||||
line = line.trim();
|
||||
String v = "";
|
||||
int idx = line.indexOf('\uff1a');
|
||||
int idx = line.indexOf(':');
|
||||
if (idx < 0)
|
||||
idx = line.indexOf(':');
|
||||
if (idx >= 0) {
|
||||
@@ -807,6 +810,8 @@ public class GUIManager implements Listener {
|
||||
if ("§a§l购物车".equals(title)
|
||||
|| "§c§l退款中心".equals(title)
|
||||
|| "§6§l商店".equals(title)
|
||||
|| "§6§l二手商店".equals(title)
|
||||
|| "§6§l我的二手上架".equals(title)
|
||||
|| title.startsWith("§e§l流水记录")
|
||||
|| title.startsWith("§e§l转账记录")
|
||||
|| title.startsWith("§d§l余额操作")) {
|
||||
@@ -814,6 +819,11 @@ public class GUIManager implements Listener {
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 批量上架 GUI(2026-10-11 任务2):允许拖入物品,由 ShopManager 自行按槽位判定
|
||||
if ("§6§l二手上架".equals(title)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 商店分类页:禁止拖拽
|
||||
if (title.startsWith("§6§l") && !title.equals(T_MAIN)
|
||||
&& !title.equals(T_MY_INFO)
|
||||
@@ -970,6 +980,34 @@ public class GUIManager implements Listener {
|
||||
return;
|
||||
}
|
||||
|
||||
// ===== 二手商店(2026-10-11 /shopbuy)=====
|
||||
if ("§6§l二手商店".equals(title)) {
|
||||
event.setCancelled(true);
|
||||
plugin.getShopManager().handleSecondHandClick(
|
||||
p, raw, event.isLeftClick());
|
||||
return;
|
||||
}
|
||||
if ("§6§l我的二手上架".equals(title)) {
|
||||
event.setCancelled(true);
|
||||
// ★ 2026-10-11 任务1:左键编辑(售价&描述),右键下架(退还物品)
|
||||
plugin.getShopManager().handleSecondHandMineClick(
|
||||
p, raw, event.isLeftClick(), event.isRightClick());
|
||||
return;
|
||||
}
|
||||
// ★ 第十二批(2026-10-13):管理员面板(在售左键下架/已下架右键上架)
|
||||
if ("§6§l二手管理".equals(title)) {
|
||||
event.setCancelled(true);
|
||||
plugin.getShopManager().handleSecondHandAdminClick(
|
||||
p, raw, event.isLeftClick(), event.isRightClick());
|
||||
return;
|
||||
}
|
||||
// ★ 第十二批(2026-10-13):下架理由 9 格面板
|
||||
if ("§6§l下架理由".equals(title)) {
|
||||
event.setCancelled(true);
|
||||
plugin.getShopManager().handleDelistReasonClick(p, raw);
|
||||
return;
|
||||
}
|
||||
|
||||
// ===== 退款中心 → 重定向订单中心 =====
|
||||
if ("§c§l退款中心".equals(title)) {
|
||||
event.setCancelled(true);
|
||||
@@ -1648,6 +1686,34 @@ public class GUIManager implements Listener {
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 第八批(2026-10-12):图标槽接入 SN 系统(此前完全没拦)——
|
||||
// ① 背包已有自定义菜单物品 → 拒绝重复载体;
|
||||
// ② 读旧雪球 SN 作换载体复用(登记不动、不触发冷静期);
|
||||
// ③ 无旧 SN 则先过申领管控(冷静期/1人1个),拦在动背包之前。
|
||||
SnManager snm = plugin.getSnManager();
|
||||
for (ItemStack pre : p.getInventory().getContents()) {
|
||||
if (isCustomMenuTrigger(pre)) {
|
||||
p.sendMessage("§c[菜单] 你已拥有菜单物品,请先清除当前图标再更换");
|
||||
return;
|
||||
}
|
||||
}
|
||||
String reuseSn = null;
|
||||
if (snm != null) {
|
||||
for (ItemStack pre : p.getInventory().getContents()) {
|
||||
if (pre != null
|
||||
&& pre.getType() == Material.SNOWBALL
|
||||
&& isMenuTriggerItem(pre)) {
|
||||
reuseSn = snm.readSn(pre);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reuseSn == null && snm != null
|
||||
&& snm.isBlocked(p, SnManager.TYPE_MENU)) {
|
||||
p.sendMessage("§c[SN] 菜单物品管控中(冷静期或已有有效登记),暂不能更换图标");
|
||||
return;
|
||||
}
|
||||
|
||||
int amount = item.getAmount();
|
||||
removeSnowballFromInventory(p); //没收雪球菜单
|
||||
// 1. 从来源移除
|
||||
@@ -1700,13 +1766,13 @@ public class GUIManager implements Listener {
|
||||
oldName = "自定义物品";
|
||||
}
|
||||
mm.setDisplayName(
|
||||
"\u00a7e\u00a7l[菜单] \u00a7f"
|
||||
"§e§l[菜单] §f"
|
||||
+ oldName);
|
||||
List<String> lore = mm.hasLore()
|
||||
? new ArrayList<>(mm.getLore())
|
||||
: new ArrayList<>();
|
||||
lore.add(0,
|
||||
"\u00a77右键打开主菜单");
|
||||
"§7右键打开主菜单");
|
||||
mm.setLore(lore);
|
||||
mm.getPersistentDataContainer().set(
|
||||
new org.bukkit.NamespacedKey(
|
||||
@@ -1718,6 +1784,23 @@ public class GUIManager implements Listener {
|
||||
menu.setItemMeta(mm);
|
||||
}
|
||||
|
||||
// 4.5 ★ 第八批:自定义菜单物品打 SN 副本——
|
||||
// 有旧雪球 SN 则转移复用;没有则新申领(入口已过管控,
|
||||
// 此处失败按 DB 异常放行不打标,与 Main.snTag 同款容错)
|
||||
String menuSn = reuseSn;
|
||||
if (menuSn == null && snm != null) {
|
||||
menuSn = snm.applySnQuiet(p,
|
||||
SnManager.TYPE_MENU, "自定义菜单图标");
|
||||
}
|
||||
if (menuSn != null && snm != null) {
|
||||
snm.writeSn(menu, menuSn, SnManager.TYPE_MENU);
|
||||
if (reuseSn != null) {
|
||||
snm.logSn(menuSn, SnManager.TYPE_MENU, "xfer",
|
||||
p.getName(),
|
||||
"菜单载体转换:雪球->自定义图标");
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 发放菜单物品
|
||||
int slot = p.getInventory()
|
||||
.firstEmpty();
|
||||
@@ -1744,6 +1827,18 @@ public class GUIManager implements Listener {
|
||||
String dbIcon = plugin.getDb()
|
||||
.getMenuIcon(p.getName());
|
||||
|
||||
// ★ 第八批:没收前先读自定义物品的 SN,发雪球时转移复用
|
||||
// (登记不动,避免旧登记把新雪球卡在「1人1个」/冷静期)
|
||||
String reuseSn = null;
|
||||
if (plugin.getSnManager() != null) {
|
||||
for (ItemStack it : p.getInventory().getContents()) {
|
||||
if (isCustomMenuTrigger(it)) {
|
||||
reuseSn = plugin.getSnManager().readSn(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 没收自定义菜单物品
|
||||
removeCustomMenuFromInventory(p);
|
||||
|
||||
@@ -1754,8 +1849,8 @@ public class GUIManager implements Listener {
|
||||
// 3. 强制清空slot 31
|
||||
topInv.setItem(31, null);
|
||||
|
||||
// 4. 发雪球
|
||||
plugin.giveMenuSnowball(p);
|
||||
// 4. 发雪球(复用旧 SN 转移登记)
|
||||
plugin.giveMenuSnowball(p, reuseSn);
|
||||
|
||||
// 5. 还原件
|
||||
if (dbIcon != null && !dbIcon.isEmpty()) {
|
||||
@@ -1782,7 +1877,7 @@ public class GUIManager implements Listener {
|
||||
List<String> lore = meta.getLore();
|
||||
if (lore == null) return false;
|
||||
for (String line : lore) {
|
||||
if (line.contains("\u00a70\u00a7k")
|
||||
if (line.contains("§0§k")
|
||||
|| line.contains("CUSTOM")) {
|
||||
return true;
|
||||
}
|
||||
@@ -1891,7 +1986,7 @@ public class GUIManager implements Listener {
|
||||
&& im.getLore() != null) {
|
||||
for (String ln : im.getLore()) {
|
||||
if (ln.contains(
|
||||
"\u00a78")) {
|
||||
"§8")) {
|
||||
p.getInventory()
|
||||
.setItem(i,
|
||||
null);
|
||||
@@ -1915,7 +2010,7 @@ public class GUIManager implements Listener {
|
||||
return true;
|
||||
ItemMeta im = item.getItemMeta();
|
||||
if (im == null) return true;
|
||||
if ("\u00a77默认菜单(雪球)"
|
||||
if ("§7默认菜单(雪球)"
|
||||
.equals(im.getDisplayName()))
|
||||
return true;
|
||||
if (im.hasLore() && im.getLore() != null
|
||||
@@ -1945,7 +2040,7 @@ public class GUIManager implements Listener {
|
||||
&& im.hasLore()
|
||||
&& im.getLore() != null) {
|
||||
for (String ln : im.getLore()) {
|
||||
if (ln.contains("\u00a77右键打开主菜单"))
|
||||
if (ln.contains("§7右键打开主菜单"))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1958,7 +2053,7 @@ public class GUIManager implements Listener {
|
||||
return true;
|
||||
ItemMeta im = item.getItemMeta();
|
||||
if (im == null) return true;
|
||||
if ("\u00a77默认菜单(雪球)"
|
||||
if ("§7默认菜单(雪球)"
|
||||
.equals(im.getDisplayName())) {
|
||||
return true;
|
||||
}
|
||||
@@ -2137,8 +2232,9 @@ public class GUIManager implements Listener {
|
||||
if (slot >= 45) break;
|
||||
String accName = (String) acc
|
||||
.get("player_name");
|
||||
long regTime = ((Number) acc
|
||||
.get("register_time")).longValue();
|
||||
// ★ 第十一批:秒/毫秒混存归一化(PHP 同步行为秒)
|
||||
long regTime = DurationParser.toMs(((Number) acc
|
||||
.get("register_time")).longValue());
|
||||
String dateStr = regTime > 0
|
||||
? sdf.format(
|
||||
new java.util.Date(regTime))
|
||||
@@ -2340,10 +2436,9 @@ public class GUIManager implements Listener {
|
||||
if (code == null || code.isEmpty()) {
|
||||
code = plugin.getInvite().generateCode(p);
|
||||
}
|
||||
int monthly = ((Number) plugin.getDb()
|
||||
.getField(p.getName(),
|
||||
"monthly_invite_count"))
|
||||
.intValue();
|
||||
// ★ 第十批:计数改派生口径(invited_at 回填后与头颅明细同源、跨月自愈)
|
||||
int monthly = plugin.getInvite()
|
||||
.getMonthlyInviteCount(p.getName());
|
||||
g.setItem(10, mkItem(Material.PAPER,
|
||||
"§b§l我的邀请码",
|
||||
"§7" + code,
|
||||
@@ -2351,12 +2446,36 @@ public class GUIManager implements Listener {
|
||||
"§e点击刷新邀请码"));
|
||||
g.setItem(12, mkItem(Material.PLAYER_HEAD,
|
||||
"§a本月邀请",
|
||||
"§7已邀请 §e" + monthly + " §7人"));
|
||||
g.setItem(16, mkItem(Material.BOOK,
|
||||
"§e输入邀请码",
|
||||
"§7绑定他人的邀请码",
|
||||
"§7已邀请 §e" + monthly + " §7人",
|
||||
"",
|
||||
"§e点击输入"));
|
||||
"§e点击查看邀请明细"));
|
||||
// ★ 第九批:已绑定邀请人则显示「邀请成功时间+邀请人」,
|
||||
// 否则显示「输入邀请码」按钮
|
||||
String invitedBy = (String) plugin.getDb()
|
||||
.getField(p.getName(), "invited_by");
|
||||
if (invitedBy != null && !invitedBy.isEmpty()) {
|
||||
Object tsObj = plugin.getDb().getField(
|
||||
p.getName(), "invited_at");
|
||||
long ts = (tsObj instanceof Number)
|
||||
? ((Number) tsObj).longValue() : 0L;
|
||||
// ★ 第十一批:秒/毫秒混存归一化(回填来源 register_time 可能为秒)
|
||||
ts = DurationParser.toMs(ts);
|
||||
String timeStr = ts > 0
|
||||
? new java.text.SimpleDateFormat(
|
||||
"yyyy-MM-dd HH:mm")
|
||||
.format(new java.util.Date(ts))
|
||||
: "未知";
|
||||
g.setItem(16, mkItem(Material.WRITTEN_BOOK,
|
||||
"§a邀请已绑定",
|
||||
"§7邀请人: §e" + invitedBy,
|
||||
"§7绑定时间: §e" + timeStr));
|
||||
} else {
|
||||
g.setItem(16, mkItem(Material.BOOK,
|
||||
"§e输入邀请码",
|
||||
"§7绑定他人的邀请码",
|
||||
"",
|
||||
"§e点击输入"));
|
||||
}
|
||||
String ugLabel = "§7(玩家)";
|
||||
UserGroupManager.UserGroupConfig ugCfg =
|
||||
plugin.getUserGroup().getHighestGroup(p.getName());
|
||||
@@ -2371,6 +2490,67 @@ public class GUIManager implements Listener {
|
||||
p.openInventory(g);
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第十批(2026-10-12):「本月邀请」明细 GUI——
|
||||
* 本月绑定我邀请码的玩家,一人一个真实玩家头颅。
|
||||
* 标题 T_MONTHLY_INVITE 已加入 Main.onInvClick 排除列表,
|
||||
* 点击全部由该分支自行 cancel(防拿走头颅)。
|
||||
*/
|
||||
public void openMonthlyInvite(Player p) {
|
||||
Inventory g = Bukkit.createInventory(
|
||||
null, 54, T_MONTHLY_INVITE);
|
||||
fillBg(g);
|
||||
List<Map<String, Object>> list = plugin.getInvite()
|
||||
.getMonthlyInvitedPlayers(p.getName());
|
||||
if (list.isEmpty()) {
|
||||
g.setItem(22, mkItem(Material.BARRIER,
|
||||
"§c本月暂无邀请记录",
|
||||
"§7邀请好友绑定你的邀请码后",
|
||||
"§7这里会展示他们的头颅"));
|
||||
} else {
|
||||
// 内容区 0-44 共 45 格;超出 45 人时留 1 格提示
|
||||
int limit = Math.min(list.size(),
|
||||
list.size() > 45 ? 44 : 45);
|
||||
for (int i = 0; i < limit; i++) {
|
||||
Map<String, Object> row = list.get(i);
|
||||
String name = String.valueOf(row.get("name"));
|
||||
long at = ((Number) row.get("at")).longValue();
|
||||
ItemStack head = new ItemStack(
|
||||
Material.PLAYER_HEAD);
|
||||
SkullMeta sm = (SkullMeta)
|
||||
head.getItemMeta();
|
||||
if (sm != null) {
|
||||
// 在线优先用精确档案;离线按玩家名解析真实皮肤
|
||||
Player target =
|
||||
Bukkit.getPlayerExact(name);
|
||||
if (target != null) {
|
||||
sm.setOwningPlayer(target);
|
||||
} else {
|
||||
sm.setOwner(name);
|
||||
}
|
||||
sm.setDisplayName("§e§l" + name);
|
||||
sm.setLore(Arrays.asList(
|
||||
"§7绑定时间: §e"
|
||||
+ new java.text.SimpleDateFormat(
|
||||
"yyyy-MM-dd HH:mm")
|
||||
.format(new java.util.Date(at)),
|
||||
"",
|
||||
"§7已计入你的本月邀请"));
|
||||
head.setItemMeta(sm);
|
||||
}
|
||||
g.setItem(i, head);
|
||||
}
|
||||
if (list.size() > 45) {
|
||||
g.setItem(44, mkItem(Material.PAPER,
|
||||
"§e还有 " + (list.size() - 44)
|
||||
+ " 人未显示"));
|
||||
}
|
||||
}
|
||||
g.setItem(49, mkItem(Material.ARROW,
|
||||
"§7返回", "§7返回「邀请数据」"));
|
||||
p.openInventory(g);
|
||||
}
|
||||
|
||||
|
||||
private String getInviteRate(int count) {
|
||||
if (count > 15) return "60%";
|
||||
|
||||
@@ -9,6 +9,14 @@ import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.scheduler.BukkitRunnable;
|
||||
import net.kyori.adventure.bossbar.BossBar;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerDropItemEvent;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -17,7 +25,7 @@ import java.sql.Date;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
|
||||
public class GarbageManager {
|
||||
public class GarbageManager implements Listener {
|
||||
|
||||
private final Main plugin;
|
||||
private Connection db;
|
||||
@@ -25,9 +33,53 @@ public class GarbageManager {
|
||||
private int maxRounds = 1;
|
||||
private boolean enabled = true;
|
||||
private int currentRound = 0;
|
||||
|
||||
/**
|
||||
* ★ 第42批:自动清理任务引用(BukkitRunnable)。
|
||||
* 热停用时 cancel 该任务;applyEnabledState() 用它判断是否已在跑。
|
||||
*/
|
||||
private BukkitRunnable cleanupTask = null;
|
||||
|
||||
/**
|
||||
* ★ 第36批:上一轮「清扫」进回收站的掉落物数量(血条展示)。
|
||||
* -1 = 还没跑过任何一轮(血条不显示该字段)。
|
||||
*/
|
||||
private int lastCleaned = -1;
|
||||
|
||||
/**
|
||||
* ★ 第36批:上一轮「永久倒掉」的过期物品数量(血条展示)。
|
||||
* -1 = 还没跑过任何一轮;非倒垃圾轮固定为 0。
|
||||
*/
|
||||
private int lastDumped = -1;
|
||||
|
||||
private final Map<String, Integer>
|
||||
recyclePages = new HashMap<>();
|
||||
|
||||
/**
|
||||
* ★ 第31批:本轮开始的墙钟时间戳(毫秒)。
|
||||
* 血条色段按真实时间判定,与 tick 计数解耦,
|
||||
* 避免“第3分30秒还是绿色”的漂移。0 = 本轮未开始。
|
||||
*/
|
||||
private long roundStartMs = 0L;
|
||||
|
||||
/**
|
||||
* ★ 第34批:已广播的倒计时节点位图。
|
||||
* 墙钟口径下 remain 可能跨值(如 301→299),原来的 `remain == 300`
|
||||
* 式判断会漏播;改用「<= 命中一次 + 位图去重」,卡顿也不漏。
|
||||
* bit0=5分钟 bit1=3分钟 bit2=1分钟 bit3=30秒 bit4=10秒
|
||||
*/
|
||||
private int announcedMask = 0;
|
||||
|
||||
/**
|
||||
* ★ 第30批:顶部血条(玩家 UUID → BossBar)
|
||||
*/
|
||||
private final Map<UUID, BossBar> bossBars = new HashMap<>();
|
||||
|
||||
/**
|
||||
* ★ 第30批:双击 Q 确认丢弃的首次按键时间(玩家 UUID → 毫秒)
|
||||
*/
|
||||
private final Map<UUID, Long> pendingDrops = new HashMap<>();
|
||||
|
||||
|
||||
public GarbageManager(Main plugin) {
|
||||
this.plugin = plugin;
|
||||
@@ -132,65 +184,435 @@ public class GarbageManager {
|
||||
|
||||
public void startAutoCleanup() {
|
||||
if (!enabled) return;
|
||||
new BukkitRunnable() {
|
||||
int countdown = 0;
|
||||
|
||||
// ★ 第42批:已在跑则不再重复调度(applyEnabledState 可能被多路热重载同时触发)
|
||||
if (cleanupTask != null) return;
|
||||
cleanupTask = new BukkitRunnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
// ★ 第42批:启停状态热更兜底 —— ConfigManager.garbageEnabled 变 false 后
|
||||
// (插件设置.txt 热重载),任务自己收摊:撤血条+清状态+取消自身。
|
||||
// 反向 false→true 时任务已不存在,靠 applyEnabledState() 重新拉起。
|
||||
if (!plugin.getConfig2().garbageEnabled) {
|
||||
enabled = false;
|
||||
hideAllBossBars();
|
||||
roundStartMs = 0L;
|
||||
announcedMask = 0;
|
||||
pendingDrops.clear();
|
||||
cleanupTask = null;
|
||||
plugin.getLogger().info(
|
||||
"[Garbage] 垃圾站_启用 热更关闭:自动清理任务已自停、血条已撤");
|
||||
this.cancel();
|
||||
return;
|
||||
}
|
||||
enabled = true;
|
||||
|
||||
// 在线人数≤0时暂停
|
||||
if (Bukkit.getOnlinePlayers()
|
||||
.isEmpty()) {
|
||||
countdown = 0;
|
||||
// ★ 第34批:墙钟口径 —— 全员离线则本轮暂停,
|
||||
// 玩家回来时重新记录起点(重新计时)
|
||||
roundStartMs = 0L;
|
||||
announcedMask = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 第31批:本轮墙钟起点(0=新轮开始,色段按真实时间判定)
|
||||
if (roundStartMs == 0L)
|
||||
roundStartMs = System.currentTimeMillis();
|
||||
|
||||
// 重新读取配置(支持热更)
|
||||
cleanupInterval =
|
||||
plugin.getConfig2()
|
||||
.garbageInterval;
|
||||
// ★ 第37批:保留轮数同样纳入热更(节点重载 插件设置.txt 后
|
||||
// 垃圾站_保留轮数 即时生效,无需重启)
|
||||
maxRounds =
|
||||
plugin.getConfig2()
|
||||
.garbageMaxRounds;
|
||||
|
||||
int remain =
|
||||
cleanupInterval - countdown;
|
||||
if (remain == 300)
|
||||
// ★ 第34批:唯一时间源(墙钟)—— 血条 / 广播 / 抢救打印 /
|
||||
// 丢弃拦截 全部读 remainSeconds(),误差 ±0 秒。
|
||||
int remain = remainSeconds();
|
||||
|
||||
// ★ 第34批:节点广播改「<= 命中一次 + 位图去重」
|
||||
// (墙钟口径 remain 可能跨值,== 判断会漏播)
|
||||
boolean nodeHit = false;
|
||||
if (cleanupInterval >= 300
|
||||
&& remain <= 300
|
||||
&& (announcedMask & 1) == 0) {
|
||||
announcedMask |= 1;
|
||||
nodeHit = true;
|
||||
broadcast("§e§l[垃圾站] §f距离清理还有 §e5分钟");
|
||||
else if (remain == 180)
|
||||
}
|
||||
if (cleanupInterval >= 180
|
||||
&& remain <= 180
|
||||
&& (announcedMask & 2) == 0) {
|
||||
announcedMask |= 2;
|
||||
nodeHit = true;
|
||||
broadcast("§e§l[垃圾站] §f距离清理还有 §e3分钟");
|
||||
else if (remain == 60)
|
||||
}
|
||||
if (cleanupInterval >= 60
|
||||
&& remain <= 60
|
||||
&& (announcedMask & 4) == 0) {
|
||||
announcedMask |= 4;
|
||||
nodeHit = true;
|
||||
broadcast("§e§l[垃圾站] §f距离清理还有 §e1分钟");
|
||||
else if (remain == 30)
|
||||
}
|
||||
if (cleanupInterval >= 30
|
||||
&& remain <= 30
|
||||
&& (announcedMask & 8) == 0) {
|
||||
announcedMask |= 8;
|
||||
nodeHit = true;
|
||||
broadcast("§e§l[垃圾站] §f距离清理还有 §e30秒");
|
||||
else if (remain == 10)
|
||||
}
|
||||
if (cleanupInterval >= 10
|
||||
&& remain <= 10
|
||||
&& (announcedMask & 16) == 0) {
|
||||
announcedMask |= 16;
|
||||
nodeHit = true;
|
||||
broadcast("§e§l[垃圾站] §f距离清理还有 §e10秒");
|
||||
else if (remain <= 5 && remain > 0)
|
||||
}
|
||||
if (nodeHit) {
|
||||
// ★ 第31批(#204/#205):垃圾箱进入下一节点 → 查一次 插件设置.txt,
|
||||
// 有变化整份重载(ConfigManager 内部比对,无变化静默、有变化打日志)
|
||||
// ★ 第42批:重载成功后把「垃圾站_启用」应用到运行态 ——
|
||||
// 此前只更新 ConfigManager 字段,GarbageManager.enabled 没人回写,
|
||||
// 导致日志喊了「true→false」但任务照跑、血条照显示(用户截图根因)。
|
||||
if (plugin.getConfig2()
|
||||
.reloadSettingsIfChanged()) {
|
||||
applyEnabledState();
|
||||
if (!enabled) return; // 本轮已被热停 → 不再往下跑
|
||||
}
|
||||
}
|
||||
if (remain <= 5 && remain > 0)
|
||||
broadcast("§e§l[垃圾站] §c" + remain + "§f...");
|
||||
else if (remain == 0) {
|
||||
if (remain == 0) {
|
||||
doCleanup();
|
||||
countdown = 0;
|
||||
// ★ 第34批:新一轮开始 —— 清墙钟起点与节点位图
|
||||
roundStartMs = 0L;
|
||||
announcedMask = 0;
|
||||
return;
|
||||
}
|
||||
countdown++;
|
||||
// ★ 第30批:顶部血条常驻倒计时(软提醒)
|
||||
// ★ 第32批:血条只受 垃圾站_启用 控制,与启发式开关解耦
|
||||
// ★ 第34批:remain 取自墙钟唯一时间源,与广播/抢救打印同源
|
||||
updateBossBars(remain);
|
||||
}
|
||||
}.runTaskTimer(plugin, 20L, 20L);
|
||||
};
|
||||
cleanupTask.runTaskTimer(plugin, 20L, 20L);
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第42批:停止自动清理任务并复位运行态(热停用时用)。
|
||||
* 撤血条 + 清按键状态 + 复位墙钟起点与节点位图,下次启用从满格重新计时。
|
||||
*/
|
||||
public void stopAutoCleanup() {
|
||||
if (cleanupTask != null) {
|
||||
try {
|
||||
cleanupTask.cancel();
|
||||
} catch (IllegalStateException ignored) {
|
||||
}
|
||||
cleanupTask = null;
|
||||
}
|
||||
enabled = false;
|
||||
hideAllBossBars();
|
||||
roundStartMs = 0L;
|
||||
announcedMask = 0;
|
||||
pendingDrops.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第42批:把 ConfigManager.garbageEnabled(插件设置.txt 的「垃圾站_启用」)
|
||||
* 应用到 GarbageManager 运行态(双向:false→true 重新拉起任务 / true→false 停任务撤血条)。
|
||||
*
|
||||
* <p>根因(第42批用户截图):reloadSettingsIfChanged() 整份重载后只更新了
|
||||
* ConfigManager 的字段并打了「已整份重载」日志,但 GarbageManager.enabled
|
||||
* 是启动时 loadConfig() 拷贝的运行态快照,没人回写 → 日志喊了 true→false,
|
||||
* 垃圾站照常运行。反向(false→true)更糟:启动时任务就没创建,
|
||||
* 之后改 true 也没人调 startAutoCleanup(),垃圾站永远起不来。
|
||||
*
|
||||
* <p>调用点:Web 热重载 / 垃圾站节点热重载 / /sdf1_login reload 命令。
|
||||
* 非主线程调用会自动切回主线程(BossBar 撤除必须主线程)。
|
||||
*/
|
||||
public void applyEnabledState() {
|
||||
if (!Bukkit.isPrimaryThread()) {
|
||||
Bukkit.getScheduler().runTask(plugin, this::applyEnabledState);
|
||||
return;
|
||||
}
|
||||
boolean want = plugin.getConfig2().garbageEnabled;
|
||||
if (want == enabled) return; // 状态一致 → no-op(防多路触发重复打日志)
|
||||
if (want) {
|
||||
enabled = true;
|
||||
startAutoCleanup();
|
||||
plugin.getLogger().info(
|
||||
"[Garbage] 垃圾站_启用 热更生效:自动清理任务已启动");
|
||||
} else {
|
||||
stopAutoCleanup();
|
||||
plugin.getLogger().info(
|
||||
"[Garbage] 垃圾站_启用 热更生效:自动清理任务已停止、血条已撤");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第30批:顶部血条常驻倒计时 + 色段软提醒。
|
||||
*
|
||||
* <p>★ 第33批(#211):色段改按「剩余时间」纯数值分段(用户明令):
|
||||
* <ul>
|
||||
* <li>剩余 ≥ 180 秒 → 绿 = 安全期</li>
|
||||
* <li>30 秒 < 剩余 < 180 秒 → 黄 = 警告(只提示快动作了,
|
||||
* 丢的时候注意安全控好量,不误导「别丢」)</li>
|
||||
* <li>剩余 ≤ 30 秒 → 红 = 危险</li>
|
||||
* </ul>
|
||||
* 第31批的「墙钟绝对时刻」方案废弃;百分比方案更早废弃(间隔一改就漂移)。
|
||||
*
|
||||
* <p>★ 第32批:血条与启发式开关解耦 —— 只要垃圾站启用,血条常驻显示;
|
||||
* 启发式开关(垃圾站_启发式拦截)关闭时只关掉「黄/红区title警告 + 末5秒双击Q拦截」,
|
||||
* 不再撤血条(否则玩家对清理状态一无所知)。
|
||||
*/
|
||||
private void updateBossBars(int remain) {
|
||||
int interval = Math.max(1, cleanupInterval);
|
||||
double progress = (double) remain / (double) interval;
|
||||
if (progress < 0.0D) progress = 0.0D;
|
||||
if (progress > 1.0D) progress = 1.0D;
|
||||
|
||||
BossBar.Color color;
|
||||
String label;
|
||||
if (remain <= 30) {
|
||||
color = BossBar.Color.RED;
|
||||
label = "§c§l危险 §7| §c马上清理,抓紧捡";
|
||||
} else if (remain < 180) {
|
||||
color = BossBar.Color.YELLOW;
|
||||
label = "§e§l注意 §7| §e快动作了,注意安全控好量";
|
||||
} else {
|
||||
color = BossBar.Color.GREEN;
|
||||
label = "§a§l安全期 §7| §a还早,放心丢";
|
||||
}
|
||||
String title = "§6§l垃圾站 §8| " + label
|
||||
+ " §8| §f剩余 " + fmtRemain(remain);
|
||||
// ★ 第36批:血条插一个字段 —— 上一轮清扫数 / 上一轮倒垃圾数
|
||||
// (还没跑过任何一轮时不显示,避免出现"上轮扫-1个")
|
||||
if (lastCleaned >= 0 && lastDumped >= 0) {
|
||||
title += " §8| §7上轮扫§e" + lastCleaned
|
||||
+ "§7个、倒§e" + lastDumped + "§7个";
|
||||
}
|
||||
|
||||
Component comp = LegacyComponentSerializer
|
||||
.legacySection().deserialize(title);
|
||||
for (Player p : Bukkit.getOnlinePlayers()) {
|
||||
UUID uuid = p.getUniqueId();
|
||||
BossBar bar = bossBars.get(uuid);
|
||||
if (bar == null) {
|
||||
// Adventure 的 BossBar 按观众分发,必须每人一份
|
||||
bar = BossBar.bossBar(comp, (float) progress,
|
||||
color, BossBar.Overlay.NOTCHED_10);
|
||||
bossBars.put(uuid, bar);
|
||||
p.showBossBar(bar);
|
||||
} else {
|
||||
bar.name(comp);
|
||||
bar.color(color);
|
||||
if (Math.abs(bar.progress() - progress) > 0.001D) {
|
||||
bar.progress((float) progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 撤掉所有玩家的顶部血条(插件关闭/停服时用;启发式开关不再触发) */
|
||||
private void hideAllBossBars() {
|
||||
for (Player p : Bukkit.getOnlinePlayers()) {
|
||||
BossBar bar = bossBars.get(p.getUniqueId());
|
||||
if (bar != null) {
|
||||
try {
|
||||
p.hideBossBar(bar);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
bossBars.clear();
|
||||
}
|
||||
|
||||
/** 剩余秒数格式化为 mm:ss */
|
||||
private String fmtRemain(int remain) {
|
||||
if (remain < 0) remain = 0;
|
||||
int m = remain / 60;
|
||||
int s = remain % 60;
|
||||
return (m < 10 ? "0" + m : String.valueOf(m))
|
||||
+ ":" + (s < 10 ? "0" + s : String.valueOf(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第34批:剩余秒数「唯一时间源」(墙钟推算)。
|
||||
*
|
||||
* <p>根因:旧实现里「血条 / 广播」读的是 tick 计数(increment 前的值),
|
||||
* 而「抢救打印 / 指令显示 / 丢弃拦截」读的是 increment 后的值,
|
||||
* 两者恒差 1 秒(用户截图:血条 02:47 vs 打印 02:46)。
|
||||
*
|
||||
* <p>现统一按墙钟推算:距本轮结束 = roundStartMs + 清理间隔 - now,
|
||||
* 向上取整到秒。血条标题、抢救提示、指令显示、丢弃拦截全部读本方法,
|
||||
* 保证 ±0 秒误差。
|
||||
*
|
||||
* @return 剩余秒数(0 = 本轮已到点 / 未开始)
|
||||
*/
|
||||
private int remainSeconds() {
|
||||
if (roundStartMs <= 0L) {
|
||||
// 本轮尚未开始(刚启动 / 抢救后 / 全员离线)→ 视为满格
|
||||
return Math.max(0, cleanupInterval);
|
||||
}
|
||||
long endMs = roundStartMs + (long) cleanupInterval * 1000L;
|
||||
long leftMs = endMs - System.currentTimeMillis();
|
||||
if (leftMs <= 0L) return 0;
|
||||
// 向上取整到秒:剩余 2.001s 显示 3 秒,2.000s 显示 2 秒
|
||||
return (int) ((leftMs + 999L) / 1000L);
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第30批:抢救模式 —— 跳过本轮清理并重新开始计时。
|
||||
* 玩家物品被错误投入垃圾箱时可急停抢救。
|
||||
*
|
||||
* @return true=抢救成功(本轮清理已取消)
|
||||
*/
|
||||
public boolean rescueRound(Player p) {
|
||||
if (!enabled) {
|
||||
if (p != null)
|
||||
p.sendMessage("§c§l[垃圾站] §f垃圾站未启用,无法抢救");
|
||||
return false;
|
||||
}
|
||||
// ★ 第34批:改用墙钟唯一时间源(与头顶血条显示值完全一致)
|
||||
if (remainSeconds() <= 0) {
|
||||
if (p != null)
|
||||
p.sendMessage("§c§l[垃圾站] §f本轮清理已经结束了,来不及抢救");
|
||||
return false;
|
||||
}
|
||||
// ★ 第31批(#203):抢救窗口 = 本轮最后 1 分钟;未到时间不可触发
|
||||
// ★ 第34批:与血条同源(墙钟)—— 打印值 = 头顶血条值(±0 秒)
|
||||
int remainSec = remainSeconds();
|
||||
if (remainSec > 60) {
|
||||
if (p != null)
|
||||
p.sendMessage("§c§l[垃圾站] §f未到抢救窗口:本轮清理还剩 §e"
|
||||
+ fmtRemain(remainSec)
|
||||
+ "§f,最后 1 分钟内才能发起抢救");
|
||||
else
|
||||
plugin.getLogger().info(
|
||||
"[垃圾站] 抢救被拒:还剩 " + remainSec
|
||||
+ " 秒,未进入最后1分钟窗口");
|
||||
return false;
|
||||
}
|
||||
// ★ 第34批:取消本轮 —— 清墙钟起点与节点位图,计时重新开始
|
||||
roundStartMs = 0L;
|
||||
announcedMask = 0;
|
||||
pendingDrops.clear();
|
||||
String who = p != null ? p.getName() : "控制台";
|
||||
broadcast("§a§l[垃圾站] §f玩家 §e" + who
|
||||
+ " §f发起抢救,本轮清理已取消,计时重新开始");
|
||||
if (p != null)
|
||||
p.sendMessage("§a§l[垃圾站] §f抢救成功!本轮清理已取消,"
|
||||
+ "倒计时重新开始");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前剩余秒数(指令/UI 显示用)。
|
||||
* ★ 第34批:与头顶血条、抢救打印完全同源(墙钟),不再有 ±1 秒差。
|
||||
*/
|
||||
public int getRemainSeconds() {
|
||||
return remainSeconds();
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第30批:垃圾箱丢弃拦截。
|
||||
*
|
||||
* <p>★ 第33批(#211)三处改造:
|
||||
* <ul>
|
||||
* <li>警告期(黄区 30~180 秒)不加任何 title 干预 —— 该期的提示
|
||||
* 只走头顶血条(updateBossBars 文案已改「注意安全控好量」);</li>
|
||||
* <li>拦截窗口从末 10 秒收窄到末 5 秒,且仅启发式开关启用时生效;</li>
|
||||
* <li>确认窗口从 2 秒收窄到 1 秒:拦下第一次 Q,1 秒内连按
|
||||
* 第二次 = 确认丢弃放行;超过 1 秒视为过期跳过(重新计时)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@EventHandler
|
||||
public void onPlayerDropItem(PlayerDropItemEvent e) {
|
||||
if (!enabled) return;
|
||||
if (!plugin.getConfig2().garbageHeuristic) return;
|
||||
// ★ 第34批:与血条同源(墙钟)
|
||||
int remain = remainSeconds();
|
||||
if (remain > 30) return; // 安全区+警告期(>30秒)→ 不干涉
|
||||
|
||||
Player p = e.getPlayer();
|
||||
// 危险期(红区 ≤30秒)小标题:按用户口径「注意安全控好量」,不写「别丢」
|
||||
p.sendTitle("", "§c§l垃圾站警告 §f马上清理了,注意安全控好量!",
|
||||
3, 30, 5);
|
||||
|
||||
if (remain > 5) return; // ★ 拦截仅在最后 5 秒窗口
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
UUID uuid = p.getUniqueId();
|
||||
Long first = pendingDrops.get(uuid);
|
||||
if (first != null && now - first <= 1000L) {
|
||||
pendingDrops.remove(uuid);
|
||||
return; // ★ 1 秒窗口内连按 2 次 Q = 确认丢弃 → 放行
|
||||
}
|
||||
// 首次按键(或上次按键已超 1 秒窗口 → 视为过期,重新计时)
|
||||
pendingDrops.put(uuid, now);
|
||||
e.setCancelled(true);
|
||||
p.sendMessage("§e§l[垃圾站] §e马上清理了!1秒内再按一次 §eQ§f 确认丢弃,"
|
||||
+ "或用 §e/垃圾抢救§f 取消本轮清理");
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onPlayerJoin(PlayerJoinEvent e) {
|
||||
// 下一轮 tick 会自动建血条(第32批:不再受启发式开关守卫),
|
||||
// 这里只清掉可能残留的按键状态
|
||||
pendingDrops.remove(e.getPlayer().getUniqueId());
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onPlayerQuit(PlayerQuitEvent e) {
|
||||
UUID uuid = e.getPlayer().getUniqueId();
|
||||
pendingDrops.remove(uuid);
|
||||
BossBar bar = bossBars.remove(uuid);
|
||||
if (bar != null) {
|
||||
try {
|
||||
e.getPlayer().hideBossBar(bar);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void doCleanup() {
|
||||
// ★ 过期轮次删除是阻塞 DB -> 先异步执行;
|
||||
// 轮次递增 / 实体收集 / 广播 必须主线程 -> 异步完成后回主线程
|
||||
final int deleteRound =
|
||||
currentRound - maxRounds;
|
||||
// ★ 第34批(#217):保留轮次语义按用户思维导图重写 ——
|
||||
// N = 目标保留轮次(配置 垃圾站_保留轮数)
|
||||
// k = 当前轮次(本方法入口时 currentRound = k-1)
|
||||
// 公式余数 = N - k;余数为 0(k % N == 0)的那一轮才「永久倒垃圾」,
|
||||
// 其余轮次只把地面掉落物收进回收站,跳过永久删除。
|
||||
// N=3 → k=1(余2)跳过 / k=2(余1)跳过 / k=3(余0)倒垃圾。
|
||||
// 到期批次 = 存活满 N 轮的批次(批次 j 存于第 j 轮,
|
||||
// 到第 j+N 轮才满 N 轮)→ j <= k - N。
|
||||
// 验证 N=2:k=1 跳过;k=2 倒垃圾(第1批尚未满2轮,删0件);
|
||||
// k=3 跳过;k=4 倒垃圾(第1、2批满2轮,删除)。
|
||||
// 验证 N=3:k=1/2 跳过;k=3 倒垃圾(无到期);k=6 倒垃圾(删第1、2、3批)。
|
||||
// 性质:每个倒垃圾轮次结束后,回收站恰好保留 N 个批次的垃圾。
|
||||
final int k = currentRound + 1; // 当前轮次(1 起)
|
||||
final int n = Math.max(1, maxRounds); // 目标保留轮次
|
||||
final boolean purgeThisRound = (k % n == 0);
|
||||
final int deleteRound = k - n; // <= 该批次的都存活满 n 轮
|
||||
Bukkit.getScheduler().runTaskAsynchronously(
|
||||
plugin, () -> {
|
||||
final int deleted =
|
||||
deleteRound >= 0
|
||||
(purgeThisRound && deleteRound >= 0)
|
||||
? deleteOldItems(deleteRound)
|
||||
: 0;
|
||||
Bukkit.getScheduler().runTask(
|
||||
plugin, () -> {
|
||||
if (deleted > 0) {
|
||||
// ★ 第39批:倒垃圾轮的「永久清理」播报保留,
|
||||
// 轮次提示改为**恒发**(原来只写在 else 分支
|
||||
// → 倒垃圾轮整轮静默,用户看不到任何轮次信息)。
|
||||
if (purgeThisRound && deleted > 0) {
|
||||
broadcast("§7[垃圾站] §f永久清理了 §e"
|
||||
+ deleted + " §f个过期物品");
|
||||
}
|
||||
|
||||
// 递增轮次
|
||||
currentRound++;
|
||||
final int roundNow =
|
||||
@@ -205,10 +627,35 @@ public class GarbageManager {
|
||||
// 收集当前掉落物
|
||||
// (实体移除必须主线程,落库已在内部转异步)
|
||||
int count = collectItems();
|
||||
// ★ 第36批:记录上一轮统计(血条展示)——
|
||||
// 清扫 = 本轮收进回收站的掉落物数;
|
||||
// 倒垃圾 = 本轮永久删除的过期物品数(非倒垃圾轮为0)
|
||||
lastCleaned = count;
|
||||
lastDumped = deleted;
|
||||
broadcast("§a§l[垃圾站] §f清理完成,"
|
||||
+ "共清理 §e" + count
|
||||
+ " §f个掉落物,存入回收站");
|
||||
|
||||
// ★ 第39批:轮次提示**恒发**,且口径改「刚开启的新一轮」。
|
||||
// 原实现算的是*刚跑完*的轮次 k(r=((k-1)%n)+1),
|
||||
// 而玩家屏幕上正在倒计时的其实是**刚开启的新一轮 k+1**
|
||||
// → 播报与实际轮次差一轮(用户截图那轮:k=25402 跑完,
|
||||
// 开启 25403 = 周期内第1轮 → 应播「当前是第1轮,
|
||||
// 第2轮倒垃圾,还剩1轮倾倒垃圾」)。
|
||||
// 周期内轮次 r = ((nextRound-1) % n) + 1 ∈ [1, n];
|
||||
// r == n 即「本轮结束时倾倒垃圾」(下一轮才真正倒)。
|
||||
final int nextRound = roundNow + 1; // 清理后开启的新一轮
|
||||
final int r = ((nextRound - 1) % n) + 1;
|
||||
if (r < n) {
|
||||
broadcast("§7[垃圾站] §f当前是第 §e" + r
|
||||
+ " §f轮,第 §e" + n
|
||||
+ " §f轮倒垃圾,还剩 §e" + (n - r)
|
||||
+ " §f轮倾倒垃圾");
|
||||
} else {
|
||||
broadcast("§7[垃圾站] §f当前是第 §e" + n
|
||||
+ " §f轮,本轮结束时倾倒垃圾");
|
||||
}
|
||||
|
||||
// 打印下次清理时间
|
||||
long nextMs = System.currentTimeMillis()
|
||||
+ (long) cleanupInterval * 1000L;
|
||||
@@ -252,7 +699,6 @@ public class GarbageManager {
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public void saveItem(ItemStack stack) {
|
||||
try {
|
||||
// ★ SN 物品进垃圾箱 = 脱离玩家本人管控,起算 12 小时找回期。
|
||||
@@ -623,9 +1069,13 @@ public class GarbageManager {
|
||||
|
||||
public void close() {
|
||||
try {
|
||||
if (db != null && !db.isClosed())
|
||||
db.close();
|
||||
hideAllBossBars();
|
||||
pendingDrops.clear();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
if (db != null && !db.isClosed())
|
||||
db.close();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ public class GiftManager {
|
||||
|| line.contains("任务内容")) {
|
||||
int colon = line.indexOf(':');
|
||||
if (colon < 0)
|
||||
colon = line.indexOf('\uff1a');
|
||||
colon = line.indexOf(':');
|
||||
if (colon >= 0) {
|
||||
rewards.add(
|
||||
line.substring(colon + 1)
|
||||
|
||||
@@ -10,7 +10,7 @@ import java.io.IOException;
|
||||
* 背包滚动自动备份。
|
||||
* <p>
|
||||
* ★ 功能:
|
||||
* 1. 由 Main 的 15 分钟定时任务驱动,遍历所有在线玩家。
|
||||
* 1. 由 Main 的准点定时任务驱动(第26批:每小时 0/15/30/45 分墙钟对齐,不再按启动时刻漂移),遍历所有在线玩家。
|
||||
* 2. 在 PVP 竞技场 / 练习场(测试场)的玩家不备份 —— 避免竞技场装备覆盖真实背包。
|
||||
* 3. 序列化 主背包 / 护甲 / 副手(extra) / 等级 / 经验,写入 inv_backup_auto 表。
|
||||
* 4. 每玩家保留最近 3 份(滚动覆盖),由 DatabaseManager.saveAutoBackup 负责清理。
|
||||
@@ -22,7 +22,7 @@ public class InventoryAutoBackup {
|
||||
|
||||
private final Main plugin;
|
||||
|
||||
/** 备份周期:15 分钟(单位 tick) */
|
||||
/** 备份周期:15 分钟(单位 tick)。★ 第26批起 Main 改墙钟对齐调度(:00/:15/:30/:45 分),此常量仅作周期语义参考,不再直接作调度入参。 */
|
||||
public static final long INTERVAL_TICKS = 15L * 60L * 20L;
|
||||
|
||||
public InventoryAutoBackup(Main plugin) {
|
||||
@@ -45,6 +45,12 @@ public class InventoryAutoBackup {
|
||||
private void backupPlayer(Player p) {
|
||||
String name = p.getName();
|
||||
|
||||
// ★ 第30批:黑名单玩家跳过自动备份
|
||||
// (拉黑时的背包快照由 BlacklistManager 单独落库,不能被自动备份覆盖)
|
||||
if (plugin.getBlacklist() != null
|
||||
&& plugin.getBlacklist().isBlacklisted(name)) {
|
||||
return;
|
||||
}
|
||||
// ★ 跳过 PVP 竞技场:竞技场内不备份,避免 PVP 装备覆盖真实背包
|
||||
if (plugin.getPVPArenaManager().isInPVPArena(name)) {
|
||||
return;
|
||||
|
||||
@@ -21,55 +21,51 @@ public class InviteManager {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void onInviteeCheckIn(String inviteeName) {
|
||||
String invitedBy = (String) plugin.getDb()
|
||||
.getField(inviteeName, "invited_by");
|
||||
if (invitedBy == null || invitedBy.isEmpty())
|
||||
return;
|
||||
/**
|
||||
* ★ 第九批(2026-10-12):绑定邀请码成功时立即计入邀请人
|
||||
* 「本月邀请」计数(带月度重置)。
|
||||
*
|
||||
* <p>旧逻辑把计数放在 onInviteeCheckIn(被邀请人签到)里,
|
||||
* 导致「绑定成功但未签到」时 GUI 恒显示「已邀请 0 人」(用户反馈
|
||||
* 的计数 bug 根因)。现改为绑定即计数——绑定才是「邀请成功」
|
||||
* 的真正时点,与债券发放同一时机,语义一致。</p>
|
||||
*
|
||||
* @param inviter 邀请人(被计数方)
|
||||
* @param invitee 被邀请人(绑定者)
|
||||
*/
|
||||
public void onInviteeBind(String inviter, String invitee) {
|
||||
if (inviter == null || inviter.isEmpty()) return;
|
||||
|
||||
String month = new SimpleDateFormat("yyyy-MM")
|
||||
.format(new Date());
|
||||
String lastMonth = (String) plugin.getDb()
|
||||
.getField(invitedBy, "invite_month");
|
||||
.getField(inviter, "invite_month");
|
||||
|
||||
if (!month.equals(lastMonth)) {
|
||||
plugin.getDb().setField(
|
||||
invitedBy, "invite_month", month);
|
||||
inviter, "invite_month", month);
|
||||
plugin.getDb().setField(
|
||||
invitedBy, "monthly_invite_count", 0);
|
||||
inviter, "monthly_invite_count", 0);
|
||||
}
|
||||
|
||||
int count = ((Number) plugin.getDb()
|
||||
.getField(invitedBy,
|
||||
.getField(inviter,
|
||||
"monthly_invite_count"))
|
||||
.intValue();
|
||||
plugin.getDb().setField(
|
||||
invitedBy, "monthly_invite_count",
|
||||
inviter, "monthly_invite_count",
|
||||
count + 1);
|
||||
}
|
||||
|
||||
double rate = 0.10;
|
||||
if (count + 1 > 15) {
|
||||
rate = 0.60;
|
||||
} else if (count + 1 > 10) {
|
||||
rate = 0.35;
|
||||
} else if (count + 1 > 5) {
|
||||
rate = 0.15;
|
||||
}
|
||||
|
||||
int bonus = (int) (1 * rate);
|
||||
if (bonus > 0) {
|
||||
plugin.getDb().addPoints(invitedBy, bonus);
|
||||
Player inviterPlayer = Bukkit
|
||||
.getPlayerExact(invitedBy);
|
||||
if (inviterPlayer != null
|
||||
&& inviterPlayer.isOnline()) {
|
||||
inviterPlayer.sendMessage(
|
||||
plugin.getConfig2()
|
||||
.msg("invite_referral_bonus")
|
||||
.replace("{points}",
|
||||
String.valueOf(bonus)));
|
||||
}
|
||||
}
|
||||
/**
|
||||
* ★ 第九批:签到链路不再重复计数——「本月邀请」计数已在
|
||||
* 绑定邀请码时由 onInviteeBind 完成(见上)。若此处再 +1,
|
||||
* 同一被邀请人每签到一次都会重复累加,造成计数虚高。
|
||||
* 方法保留以兼容既有三处签到调用点(Main.dailySign 等),
|
||||
* 但已清空计数逻辑。
|
||||
*/
|
||||
public void onInviteeCheckIn(String inviteeName) {
|
||||
// 计数已迁移至 onInviteeBind(绑定时),签到不再计数
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getAllInviteData() {
|
||||
@@ -93,4 +89,207 @@ public class InviteManager {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 第十批(2026-10-12)存量自愈 + 派生口径 ====================
|
||||
|
||||
/** 本月 1 号 00:00(本地时区)毫秒时间戳 */
|
||||
private long monthStartTime() {
|
||||
Calendar c = Calendar.getInstance();
|
||||
c.set(Calendar.DAY_OF_MONTH, 1);
|
||||
c.set(Calendar.HOUR_OF_DAY, 0);
|
||||
c.set(Calendar.MINUTE, 0);
|
||||
c.set(Calendar.SECOND, 0);
|
||||
c.set(Calendar.MILLISECOND, 0);
|
||||
return c.getTimeInMillis();
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? "" : o.toString();
|
||||
}
|
||||
|
||||
private static long lng(Object o) {
|
||||
return (o instanceof Number)
|
||||
? ((Number) o).longValue() : 0L;
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第十批(2026-10-12):存量邀请数据自愈,
|
||||
* onEnable 启动时调用一次(幂等)。
|
||||
*
|
||||
* <p>① 回填 invited_at=0 的存量绑定时间——绑定功能自项目
|
||||
* 初始化(2026-05)就存在但没记时间,用户 GUI 看到
|
||||
* 「绑定时间: 未知」。回填优先级:</p>
|
||||
* <ol>
|
||||
* <li>bond_transaction type='invitee_reward'
|
||||
* (第九批起被邀请人收款,time=绑定时刻,最精确)</li>
|
||||
* <li>bond_transaction type='invite_reward' 且
|
||||
* target_player=被邀请人(第八批起邀请人收款)</li>
|
||||
* <li>注册时间(早期绑定大多发生在注册前后,最佳代理)</li>
|
||||
* <li>最后登录时间 → 当前时间</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>② 按回填后的口径重算 monthly_invite_count / invite_month
|
||||
* ——修复存量绑定不计入导致的「已邀请 0 人」。</p>
|
||||
*/
|
||||
public void reconcileInviteData() {
|
||||
try {
|
||||
List<Map<String, Object>> users =
|
||||
plugin.getDb().getAllUsers();
|
||||
long now = System.currentTimeMillis();
|
||||
int fixedAt = 0;
|
||||
// ---- ① 回填存量绑定时间 ----
|
||||
for (Map<String, Object> u : users) {
|
||||
String name = str(u.get("player_name"));
|
||||
if (name.isEmpty()) continue;
|
||||
if (str(u.get("invited_by")).isEmpty()) continue;
|
||||
long invAt = lng(u.get("invited_at"));
|
||||
if (invAt > 0 && invAt < 1000000000000L) {
|
||||
// ★ 第十一批:秒级存量(回填时继承了秒级
|
||||
// register_time)→ 原地转毫秒
|
||||
plugin.getDb().setField(name, "invited_at",
|
||||
invAt * 1000L);
|
||||
u.put("invited_at", invAt * 1000L);
|
||||
fixedAt++;
|
||||
}
|
||||
if (lng(u.get("invited_at")) > 0) continue;
|
||||
long ts = lookupBondTx(
|
||||
"player_name", name,
|
||||
"invitee_reward");
|
||||
if (ts <= 0) ts = lookupBondTx(
|
||||
"target_player", name,
|
||||
"invite_reward");
|
||||
if (ts <= 0) ts = DurationParser.toMs(
|
||||
lng(u.get("register_time")));
|
||||
if (ts <= 0) ts = DurationParser.toMs(
|
||||
lng(u.get("last_login_time")));
|
||||
if (ts <= 0) ts = now;
|
||||
plugin.getDb().setField(
|
||||
name, "invited_at", ts);
|
||||
u.put("invited_at", ts);
|
||||
fixedAt++;
|
||||
}
|
||||
// ---- ② 重算月度计数(仅维护邀请人,与 GUI 派生口径同源) ----
|
||||
long monthStart = monthStartTime();
|
||||
Map<String, Integer> counter = new HashMap<>();
|
||||
for (Map<String, Object> u : users) {
|
||||
String inviter = str(u.get("invited_by"));
|
||||
if (inviter.isEmpty()) continue;
|
||||
if (DurationParser.toMs(
|
||||
lng(u.get("invited_at"))) >= monthStart) {
|
||||
counter.merge(inviter, 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
String curMonth = new SimpleDateFormat("yyyy-MM")
|
||||
.format(new Date());
|
||||
int fixedCnt = 0;
|
||||
for (Map<String, Object> u : users) {
|
||||
String name = str(u.get("player_name"));
|
||||
if (name.isEmpty()) continue;
|
||||
// 只维护邀请人(有邀请码的玩家)
|
||||
if (str(u.get("invite_code")).isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
int want = counter.getOrDefault(name, 0);
|
||||
if (lng(u.get("monthly_invite_count")) != want) {
|
||||
plugin.getDb().setField(
|
||||
name, "monthly_invite_count",
|
||||
want);
|
||||
fixedCnt++;
|
||||
}
|
||||
if (!curMonth.equals(
|
||||
str(u.get("invite_month")))) {
|
||||
plugin.getDb().setField(
|
||||
name, "invite_month", curMonth);
|
||||
}
|
||||
}
|
||||
if (fixedAt > 0 || fixedCnt > 0) {
|
||||
plugin.getLogger().info(
|
||||
"[Invite] 存量自愈: 回填绑定时间 "
|
||||
+ fixedAt + " 条, 重算月度计数 "
|
||||
+ fixedCnt + " 人");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning(
|
||||
"[Invite] 存量邀请数据自愈失败: "
|
||||
+ e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 bond.db 的 bond_transaction 里查该玩家某类流水的最早时间
|
||||
* (跨库:必须走 BondManager.getConnection(),
|
||||
* bond_transaction 不在 login.db)
|
||||
*/
|
||||
private long lookupBondTx(String col, String player,
|
||||
String type) {
|
||||
try {
|
||||
java.sql.PreparedStatement ps = plugin.getBonds()
|
||||
.getConnection().prepareStatement(
|
||||
"SELECT MIN(time) "
|
||||
+ "FROM bond_transaction "
|
||||
+ "WHERE type=? AND "
|
||||
+ col + "=?");
|
||||
ps.setString(1, type);
|
||||
ps.setString(2, player);
|
||||
java.sql.ResultSet rs = ps.executeQuery();
|
||||
long t = rs.next() ? rs.getLong(1) : 0L;
|
||||
rs.close();
|
||||
ps.close();
|
||||
// 兼容以秒为单位的历史行
|
||||
if (t > 0 && t < 1000000000000L) t *= 1000L;
|
||||
return t;
|
||||
} catch (Exception e) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第十批:派生口径的「本月邀请人数」——
|
||||
* 按 invited_at >= 本月 1 号实时统计(GUI 显示用,
|
||||
* 跨月自愈,不依赖增量列 monthly_invite_count)。
|
||||
*/
|
||||
public int getMonthlyInviteCount(String inviter) {
|
||||
if (inviter == null || inviter.isEmpty()) return 0;
|
||||
long monthStart = monthStartTime();
|
||||
int n = 0;
|
||||
for (Map<String, Object> u
|
||||
: plugin.getDb().getAllUsers()) {
|
||||
if (!inviter.equals(
|
||||
str(u.get("invited_by")))) continue;
|
||||
// ★ 第十一批:秒/毫秒归一化后再比较
|
||||
if (DurationParser.toMs(
|
||||
lng(u.get("invited_at"))) >= monthStart) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第十批:本月绑定该玩家邀请码的玩家列表(头颅明细 GUI)。
|
||||
*
|
||||
* @return 每项含 name(被邀请人)与 at(绑定时间戳),
|
||||
* 按绑定时间倒序
|
||||
*/
|
||||
public List<Map<String, Object>> getMonthlyInvitedPlayers(
|
||||
String inviter) {
|
||||
List<Map<String, Object>> result =
|
||||
new ArrayList<>();
|
||||
if (inviter == null || inviter.isEmpty()) return result;
|
||||
long monthStart = monthStartTime();
|
||||
for (Map<String, Object> u
|
||||
: plugin.getDb().getAllUsers()) {
|
||||
if (!inviter.equals(
|
||||
str(u.get("invited_by")))) continue;
|
||||
// ★ 第十一批:秒/毫秒归一化后再比较与展示
|
||||
long ts = DurationParser.toMs(
|
||||
lng(u.get("invited_at")));
|
||||
if (ts < monthStart) continue;
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("name", str(u.get("player_name")));
|
||||
row.put("at", ts);
|
||||
result.add(row);
|
||||
}
|
||||
result.sort((a, b) -> Long.compare(
|
||||
lng(b.get("at")), lng(a.get("at"))));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.Material;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 物品名翻译中间人(2026-10-11,v2 改用官方 zh_cn.json)。
|
||||
* <p>
|
||||
* 玩家在聊天栏输入物品 ID 时,不是人人都能背下 MC 每个物品的英文 ID。
|
||||
* 本类把中文名(如「钻石」)翻译为标准英文 Material 名(如 diamond),
|
||||
* 入库统一用英文标准名,回收/匹配时按英文名比对。
|
||||
* <p>
|
||||
* 数据来源:{@link OfficialTranslations#reverseMap()} —— 官方原版 zh_cn.json
|
||||
* (plugins/Sdf1_login/zh_cn.json,启动时自动加载/下载,领地容器查询等已在用)。
|
||||
* 不再维护手工中文映射表。
|
||||
* <p>
|
||||
* 解析顺序:
|
||||
* 1. 英文直通(Material.matchMaterial,支持 minecraft: 前缀与 legacy 名)
|
||||
* 2. 官方中文精确(zh_cn.json 反向索引)
|
||||
* 3. 归一化后精确(去空格、去 minecraft: 前缀)
|
||||
* 4. 唯一前缀匹配(输入「钻」时,若全表只有一个候选以它开头则放行并回填提示)
|
||||
*/
|
||||
public final class ItemNameResolver {
|
||||
|
||||
private ItemNameResolver() {}
|
||||
|
||||
/**
|
||||
* 解析玩家输入为 Material。
|
||||
*
|
||||
* @param input 玩家输入(英文 ID / minecraft: 前缀 / 官方中文名)
|
||||
* @return Material;无法解析返回 null
|
||||
*/
|
||||
public static Material resolve(String input) {
|
||||
return resolveWithHint(input, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并附带「实际匹配到的中文名」用于提示(如输入「钻」且唯一命中「钻石剑」时回填)。
|
||||
*/
|
||||
public static Material resolveWithHint(String input, StringBuilder hintOut) {
|
||||
if (input == null) return null;
|
||||
String raw = input.trim();
|
||||
if (raw.isEmpty()) return null;
|
||||
|
||||
// 1. 英文直通(含 minecraft: 前缀与 legacy 名)
|
||||
Material m = Material.matchMaterial(raw);
|
||||
if (m != null) return m;
|
||||
|
||||
Map<String, List<String>> rev = OfficialTranslations.reverseMap();
|
||||
if (rev.isEmpty()) return null; // zh_cn.json 未加载,仅支持英文
|
||||
|
||||
// 2. 官方中文精确
|
||||
Material hit = byChinese(rev, raw);
|
||||
if (hit != null) return hit;
|
||||
|
||||
// 3. 归一化后再查(去空格、去 minecraft: 前缀、小写)
|
||||
String norm = raw.replace(" ", "");
|
||||
if (!norm.equals(raw)) {
|
||||
m = Material.matchMaterial(norm);
|
||||
if (m != null) return m;
|
||||
hit = byChinese(rev, norm);
|
||||
if (hit != null) return hit;
|
||||
} else {
|
||||
norm = raw;
|
||||
}
|
||||
|
||||
// 4. 唯一前缀匹配(全表只有一个候选以它开头才放行)
|
||||
String firstKey = null;
|
||||
int hits = 0;
|
||||
for (String zh : rev.keySet()) {
|
||||
if (zh.startsWith(norm) && !zh.equals(norm)) {
|
||||
hits++;
|
||||
if (hits == 1) firstKey = zh;
|
||||
if (hits > 1) break;
|
||||
}
|
||||
}
|
||||
if (hits == 1 && firstKey != null) {
|
||||
Material cand = byChinese(rev, firstKey);
|
||||
if (cand != null) {
|
||||
if (hintOut != null) hintOut.append(firstKey);
|
||||
return cand;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 用中文名查反向索引并转 Material;未命中或非法返回 null。 */
|
||||
private static Material byChinese(Map<String, List<String>> rev, String zh) {
|
||||
List<String> names = rev.get(zh);
|
||||
if (names == null) return null;
|
||||
for (String en : names) {
|
||||
Material m = Material.matchMaterial(en);
|
||||
if (m != null) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 英文标准名 → 中文显示名。
|
||||
* 优先官方 zh_cn.json(OfficialTranslations),未命中回退 Title Case。
|
||||
*/
|
||||
public static String toChinese(Material mat) {
|
||||
if (mat == null) return "";
|
||||
String official = OfficialTranslations.lookUp(mat.name());
|
||||
if (official != null && !official.isEmpty()) return official;
|
||||
// 回退:下划线转空格的 Title Case
|
||||
String[] parts = mat.name().split("_");
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String part : parts) {
|
||||
if (sb.length() > 0) sb.append(" ");
|
||||
sb.append(part.substring(0, 1).toUpperCase())
|
||||
.append(part.substring(1).toLowerCase());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -83,9 +83,10 @@ public class LoginManager {
|
||||
plugin.getDb().setField(name,
|
||||
"register_time",
|
||||
System.currentTimeMillis());
|
||||
plugin.restoreInventory(p);
|
||||
// ★ 二十二批:DB 读挪异步线程,菜单雪球延后到恢复完成回调
|
||||
plugin.restoreInventory(p,
|
||||
() -> plugin.giveMenuSnowball(p));
|
||||
plugin.recordIPLogin(p);
|
||||
plugin.giveMenuSnowball(p);
|
||||
|
||||
p.sendMessage(plugin.getConfig2()
|
||||
.msg("reg_success"));
|
||||
@@ -286,9 +287,10 @@ public class LoginManager {
|
||||
p.setAllowFlight(false);
|
||||
p.setFlying(false);
|
||||
// 只在有有效备份时恢复
|
||||
plugin.restoreInventory(p);
|
||||
// ★ 二十二批:DB 读挪异步线程,菜单雪球延后到恢复完成回调
|
||||
plugin.restoreInventory(p,
|
||||
() -> plugin.giveMenuSnowball(p));
|
||||
plugin.recordIPLogin(p);
|
||||
plugin.giveMenuSnowball(p);
|
||||
if (tempPassword) {
|
||||
// ★ 确保这行存在
|
||||
plugin.getNeedsPasswordChange().add(name);
|
||||
|
||||
+1147
-145
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.lang.management.ThreadInfo;
|
||||
import java.lang.management.ThreadMXBean;
|
||||
|
||||
/**
|
||||
* 主线程卡顿看门狗(第十五批,2026-10-14)。
|
||||
*
|
||||
* <p>背景:生产服 2026-10-14 14:52:08~14:52:29 主线程静默 21 秒
|
||||
* (玩家在「我的信息」GUI 做自定义菜单图标交换期间),全程无 ERROR、
|
||||
* 只有 SSL 断路器 WARN —— 日志里没有任何栈信息能定位卡在哪一帧。
|
||||
* 本看门狗用于下次复现时直接抓出现场。
|
||||
*
|
||||
* <p>机制:
|
||||
* <ol>
|
||||
* <li>主线程每 tick 心跳(同步定时任务,主线程一卡心跳就停走);</li>
|
||||
* <li>独立 daemon 线程每 1 秒检查心跳年龄,≥5 秒 → 立刻把主线程栈
|
||||
* (深度 120 帧)打进日志,标签 {@code [主线程看门狗]};</li>
|
||||
* <li>持续卡顿每 10 秒补一次栈(观察卡点是否在变);</li>
|
||||
* <li>恢复后补一条「恢复」日志,带总卡顿时长;</li>
|
||||
* <li>若看门狗自己 sleep 都睡了 3 倍以上时长 → JVM/主机级停顿,
|
||||
* 单独记一条(此时单线程栈已无意义)。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>生命周期:daemon 线程 + {@code while (plugin.isEnabled())} 自退出,
|
||||
* onDisable 后心跳任务被调度器取消 → 1 秒内自然结束,无需手动 stop。
|
||||
*/
|
||||
public final class MainThreadWatchdog implements Runnable {
|
||||
|
||||
/** 心跳超过这个年龄视为主线程卡顿 */
|
||||
private static final long STALL_MS = 5000L;
|
||||
/** 检查间隔 */
|
||||
private static final long CHECK_MS = 1000L;
|
||||
/** 持续卡顿时补栈间隔 */
|
||||
private static final long REDUMP_MS = 10000L;
|
||||
/** 抓栈深度 */
|
||||
private static final int DEPTH = 120;
|
||||
|
||||
private final JavaPlugin plugin;
|
||||
private volatile long heartbeat;
|
||||
private volatile boolean stalled;
|
||||
private volatile long stallStartAt;
|
||||
private volatile long lastDumpAt;
|
||||
|
||||
public MainThreadWatchdog(JavaPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
/** 在 onEnable 里调用一次即可。 */
|
||||
public void start() {
|
||||
heartbeat = System.currentTimeMillis();
|
||||
// 主线程心跳:每 tick 刷一次;主线程被卡住时它自然停走
|
||||
Bukkit.getScheduler().runTaskTimer(plugin, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
heartbeat = System.currentTimeMillis();
|
||||
}
|
||||
}, 1L, 1L);
|
||||
Thread t = new Thread(this, "sdf1-main-watchdog");
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
plugin.getLogger().info("[主线程看门狗] 已启动(主线程卡顿≥"
|
||||
+ (STALL_MS / 1000) + " 秒自动抓栈打日志)");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
while (plugin.isEnabled()) {
|
||||
long sleepBegin = System.nanoTime();
|
||||
try {
|
||||
Thread.sleep(CHECK_MS);
|
||||
} catch (InterruptedException ie) {
|
||||
return;
|
||||
}
|
||||
long sleptMs = (System.nanoTime() - sleepBegin) / 1000000L;
|
||||
if (sleptMs > CHECK_MS * 3) {
|
||||
// 连看门狗自己都被冻住 → JVM/GC/主机级停顿,栈已无意义
|
||||
plugin.getLogger().warning("[主线程看门狗] ★ 全线程停顿约 "
|
||||
+ sleptMs + "ms(JVM/主机级,非单点死锁)");
|
||||
}
|
||||
if (!plugin.isEnabled()) return;
|
||||
long now = System.currentTimeMillis();
|
||||
long age = now - heartbeat;
|
||||
if (age >= STALL_MS) {
|
||||
if (!stalled) {
|
||||
stalled = true;
|
||||
stallStartAt = now;
|
||||
lastDumpAt = now;
|
||||
dumpMainThread(age, true);
|
||||
} else if (now - lastDumpAt >= REDUMP_MS) {
|
||||
lastDumpAt = now;
|
||||
dumpMainThread(age, false);
|
||||
}
|
||||
} else if (stalled) {
|
||||
stalled = false;
|
||||
plugin.getLogger().warning("[主线程看门狗] ✓ 主线程已恢复,本次卡顿共 "
|
||||
+ (now - stallStartAt) + "ms");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void dumpMainThread(long ageMs, boolean first) {
|
||||
plugin.getLogger().warning("[主线程看门狗] ★ 主线程已 " + ageMs
|
||||
+ "ms 未推进!"
|
||||
+ (first ? "首次抓栈(卡点看最上面几帧):" : "补栈:"));
|
||||
try {
|
||||
Thread main = null;
|
||||
for (Thread t : Thread.getAllStackTraces().keySet()) {
|
||||
if ("Server thread".equals(t.getName())) {
|
||||
main = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (main == null) {
|
||||
plugin.getLogger().warning(" (没找到 Server thread)");
|
||||
return;
|
||||
}
|
||||
ThreadMXBean mx = ManagementFactory.getThreadMXBean();
|
||||
ThreadInfo ti = mx.getThreadInfo(main.getId(), DEPTH);
|
||||
if (ti == null) {
|
||||
plugin.getLogger().warning(" (拿不到主线程 ThreadInfo)");
|
||||
return;
|
||||
}
|
||||
plugin.getLogger().warning(" 主线程状态: " + ti.getThreadState());
|
||||
for (StackTraceElement el : ti.getStackTrace()) {
|
||||
plugin.getLogger().warning(" at " + el);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
plugin.getLogger().warning(" 抓栈失败: " + t);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,8 @@ public class MaintenanceManager implements Listener {
|
||||
"&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]*)");
|
||||
/** 时长单位正则:单位部分匹配「字母 + 汉字区(一~龥)」,例:10秒 / 5分 / 2小时 */
|
||||
private static final Pattern DUR_PAT = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*([a-zA-Z一-龥]*)");
|
||||
private static final Pattern AMP_COLOR_PAT = Pattern.compile("&([0-9a-fk-orA-FK-OR])");
|
||||
|
||||
private final Main plugin;
|
||||
@@ -701,8 +702,7 @@ public class MaintenanceManager implements Listener {
|
||||
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 stripped = s.replaceAll("[\\[\\]【】〔〕「」『』()()\\-=_::·*#\\s]+", "");
|
||||
String low = stripped.toLowerCase(Locale.ROOT);
|
||||
// 精确匹配常见写法;再兜底 endsWith 兼容「玩家白名单」「ALLOWLIST」这类变体标题
|
||||
//(有了上面的 = 门槛,endsWith 已经不会再吞掉控制段键值对)
|
||||
@@ -781,7 +781,7 @@ public class MaintenanceManager implements Listener {
|
||||
s = s.substring(0, c).trim();
|
||||
}
|
||||
// 列表装饰前缀:- > * · •(合法 MC 名不会以这些字符开头)
|
||||
String noDecor = s.replaceFirst("^[-*>\\u00b7\\u2022]\\s+", "");
|
||||
String noDecor = s.replaceFirst("^[-*>·•]\\s+", "");
|
||||
if (!noDecor.equals(s)) {
|
||||
warn(log, "白名单条目前缀符号已忽略: [" + s + "] -> [" + noDecor + "]");
|
||||
s = noDecor;
|
||||
@@ -790,9 +790,9 @@ public class MaintenanceManager implements Listener {
|
||||
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) {
|
||||
if (s.indexOf(',') >= 0 || s.indexOf(',') >= 0 || s.indexOf('、') >= 0
|
||||
|| s.indexOf(';') >= 0 || s.indexOf(';') >= 0 || s.indexOf(' ') >= 0
|
||||
|| s.indexOf(UnicodeChars.IDEOGRAPHIC_SPACE) >= 0 || s.indexOf('\t') >= 0) {
|
||||
warn(log, "白名单条目不符合「一行一个玩家名」,已按整行收录: " + s);
|
||||
}
|
||||
wl.add(s);
|
||||
@@ -801,13 +801,13 @@ public class MaintenanceManager implements Listener {
|
||||
/** 去掉肉眼看不见却会让匹配失败的字符:零宽/BOM/不间断空格/全角空格 */
|
||||
private static String stripInvisible(String s) {
|
||||
if (s == null) return null;
|
||||
return s.replaceAll("[\\u200B-\\u200D\\u2060\\uFEFF\\u00A0\\u3000]", "");
|
||||
return s.replaceAll(UnicodeChars.INVISIBLE_STRIP_CLASS, "");
|
||||
}
|
||||
|
||||
/** 行内注释起点(# / # / //),返回 -1 表示没有 */
|
||||
private static int indexOfInlineComment(String s) {
|
||||
int h = s.indexOf('#');
|
||||
if (h < 0) h = s.indexOf('\uFF03');
|
||||
if (h < 0) h = s.indexOf('#');
|
||||
if (h < 0) h = s.indexOf("//");
|
||||
return h <= 0 ? -1 : h;
|
||||
}
|
||||
|
||||
@@ -309,6 +309,35 @@ public class MaterialTranslator {
|
||||
*/
|
||||
public static void clearCache() {
|
||||
cache.clear();
|
||||
chatCache.clear();
|
||||
}
|
||||
|
||||
// ==================== 聊天整句机翻(28批新增) ====================
|
||||
|
||||
/** 聊天机翻独立缓存(与材料名缓存隔离),上限保护防内存膨胀 */
|
||||
private static final ConcurrentHashMap<String, String> chatCache = new ConcurrentHashMap<>();
|
||||
private static final int CHAT_CACHE_MAX = 500;
|
||||
|
||||
/**
|
||||
* 聊天整句机翻(英→中)。复用 translateWithFallback 的
|
||||
* YOUDAO→JIANXIN→GOOGLE 降级链(与领地系统同一套 API 流程)。
|
||||
* 结果独立缓存(上限500条,满则整体清空重来),不污染材料名缓存。
|
||||
* 同步阻塞 HTTP(每源 3s 超时),调用方必须放在异步线程执行。
|
||||
* 返回 null 表示翻译失败或译文无意义(如与原文相同)。
|
||||
*/
|
||||
public static String translateChatText(String text) {
|
||||
if (text == null) return null;
|
||||
String key = text.trim();
|
||||
if (key.isEmpty()) return null;
|
||||
String hit = chatCache.get(key);
|
||||
if (hit != null) return hit;
|
||||
String zh = translateWithFallback(key);
|
||||
if (zh != null && !zh.isEmpty() && !zh.equals(key)) {
|
||||
if (chatCache.size() >= CHAT_CACHE_MAX) chatCache.clear();
|
||||
chatCache.put(key, zh);
|
||||
return zh;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,8 @@ public class OfficialTranslations {
|
||||
|
||||
private static final ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
|
||||
private static final Gson gson = new Gson();
|
||||
/** 反向索引(中文→英文键),懒构建;loadFromJson 后置 null 触发重建 */
|
||||
private static volatile Map<String, java.util.List<String>> reverseIndex;
|
||||
|
||||
// ---------- 对外 API ----------
|
||||
|
||||
@@ -86,33 +88,46 @@ public class OfficialTranslations {
|
||||
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;
|
||||
}
|
||||
if (val != null) return val;
|
||||
|
||||
// 2. block.minecraft.<name>
|
||||
val = map.get("block.minecraft." + lower);
|
||||
if (val != null) {
|
||||
Bukkit.getLogger().info("[翻译] officialLookUp 命中(block): " + lower + " → " + val);
|
||||
return val;
|
||||
}
|
||||
if (val != null) return val;
|
||||
|
||||
// 3. item.minecraft.<name>
|
||||
val = map.get("item.minecraft." + lower);
|
||||
if (val != null) {
|
||||
Bukkit.getLogger().info("[翻译] officialLookUp 命中(item): " + lower + " → " + val);
|
||||
return val;
|
||||
}
|
||||
if (val != null) return val;
|
||||
|
||||
Bukkit.getLogger().info("[翻译] officialLookUp 未命中: " + lower + ",将走机翻兜底");
|
||||
return null; // 未命中 -> 调用者走机翻
|
||||
return null; // 未命中 -> 调用者走机翻兜底
|
||||
}
|
||||
|
||||
/**
|
||||
* 反向索引:中文名 → 英文键列表(如 「钻石剑」→ ["diamond_sword"])。<br>
|
||||
* 仅收录 item.minecraft.* / block.minecraft.* 条目;同名时 item 优先(先扫 item 再扫 block)。<br>
|
||||
* 懒构建 + volatile,zh_cn.json 重新 load 后失效重建。
|
||||
*/
|
||||
public static Map<String, java.util.List<String>> reverseMap() {
|
||||
Map<String, java.util.List<String>> idx = reverseIndex;
|
||||
if (idx != null) return idx;
|
||||
synchronized (OfficialTranslations.class) {
|
||||
if (reverseIndex != null) return reverseIndex;
|
||||
Map<String, java.util.List<String>> built = new java.util.HashMap<>();
|
||||
for (String prefix : new String[]{"item.minecraft.", "block.minecraft."}) {
|
||||
for (Map.Entry<String, String> e : map.entrySet()) {
|
||||
if (!e.getKey().startsWith(prefix)) continue;
|
||||
String zh = e.getValue();
|
||||
if (zh == null || zh.isEmpty()) continue;
|
||||
String mat = e.getKey().substring(prefix.length());
|
||||
java.util.List<String> list = built.computeIfAbsent(zh,
|
||||
k -> new java.util.ArrayList<>(2));
|
||||
if (!list.contains(mat)) list.add(mat);
|
||||
}
|
||||
}
|
||||
reverseIndex = built;
|
||||
return built;
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前加载的条目数(调试用) */
|
||||
@@ -135,6 +150,7 @@ public class OfficialTranslations {
|
||||
for (Map.Entry<String, com.google.gson.JsonElement> entry : obj.entrySet()) {
|
||||
map.put(entry.getKey(), entry.getValue().getAsString());
|
||||
}
|
||||
reverseIndex = null; // 失效反向索引,下次访问重建
|
||||
}
|
||||
|
||||
/** 从 Mojang API 拉取 zh_cn.json(完整链:manifest → version → asset index → object) */
|
||||
|
||||
@@ -146,10 +146,50 @@ public class OpWhiteListManager implements Listener {
|
||||
// a) 15s tick 只做 checkAndReload()(指纹变化才重载+打日志,无变化静默 apply)
|
||||
// b) 整点扫描做 checkAndReload() + apply()(撤销非法持有 + 补授权)
|
||||
// 且【无更新则不打印】心跳行——见 onQuarterPoint()
|
||||
Bukkit.getScheduler().runTaskTimer(plugin, this::checkAndReload, 300L, 300L);
|
||||
long delayMs = millisToNextQuarterPoint();
|
||||
Bukkit.getScheduler().runTaskTimer(plugin, this::onQuarterPoint,
|
||||
(long) delayMs / 50L, 18000L);
|
||||
// ★ 2026-10-13 问题3:初始延迟原来写死 300 tick=15s,从插件启动时刻起算,
|
||||
// 于是轮询点整体漂移固定偏移(实际 6/21/36/51 秒,期望 0/15/30/45 秒)。
|
||||
// 改为对齐到下一个 15 秒墙钟边界后,再按 15s 周期跑(同 startShCfgReloadTask 范式)。
|
||||
// ★ 2026-10-14 问题4(第十三批复发根因):上一版只对齐了【初始延迟】,
|
||||
// 固定 period(300/18000 tick)在 TPS≠20 时逐轮累积漂移,运行几小时后
|
||||
// 轮询点又飘离 0/15/30/45 秒整点。改为墙钟自对齐、每轮执行完重排的
|
||||
// runTaskLater 调度(见 scheduleAligned),无论 TPS 多少都精确落点。
|
||||
scheduleAligned(this::checkAndReload, 15000L, "15s热重载");
|
||||
scheduleAligned(this::onQuarterPoint, 900000L, "整点扫描");
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 2026-10-14 问题4:墙钟自对齐、每轮重排的调度器(替代固定 period 的 runTaskTimer)。
|
||||
* 每轮任务跑完后按【执行时刻的墙钟】重新计算下一个 periodMs 边界再 runTaskLater,
|
||||
* 漂移不累积 —— TPS 高低、卡顿都影响不了落点;任务异常也不打断重排(finally)。
|
||||
*
|
||||
* @param task 每轮要跑的任务
|
||||
* @param periodMs 墙钟周期毫秒(15000=对齐 :00/:15/:30/:45 秒;900000=对齐 :00/:15/:30/:45 分)
|
||||
* @param tag 日志标签(异常时打印用)
|
||||
*/
|
||||
private void scheduleAligned(Runnable task, long periodMs, String tag) {
|
||||
Runnable self = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (!plugin.isEnabled()) {
|
||||
return; // 插件已禁用,不再重排
|
||||
}
|
||||
try {
|
||||
task.run();
|
||||
} catch (Throwable t) {
|
||||
plugin.getLogger().warning("[OP白名单] " + tag + "任务异常(下轮照常): "
|
||||
+ t.getMessage());
|
||||
} finally {
|
||||
long now = System.currentTimeMillis();
|
||||
long next = (now / periodMs + 1) * periodMs;
|
||||
long delayTicks = Math.max(1L, (next - now + 49L) / 50L);
|
||||
Bukkit.getScheduler().runTaskLater(plugin, this, delayTicks);
|
||||
}
|
||||
}
|
||||
};
|
||||
long now = System.currentTimeMillis();
|
||||
long next = (now / periodMs + 1) * periodMs;
|
||||
long delayTicks = Math.max(1L, (next - now + 49L) / 50L);
|
||||
Bukkit.getScheduler().runTaskLater(plugin, self, delayTicks);
|
||||
}
|
||||
|
||||
// ================= 配置读取 =================
|
||||
@@ -378,8 +418,15 @@ public class OpWhiteListManager implements Listener {
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs();
|
||||
}
|
||||
// 原版 ops.json 不存在或为空 = 没有内容可丢;已有历史备份则原样保留(绝不用空文件覆盖)
|
||||
if (!src.exists() || src.length() == 0) {
|
||||
// 原版 ops.json 不存在 / 空白 / 只剩 "[]" 空壳 = 没有内容可丢;
|
||||
// 一律保留已有历史备份(★ 2026-10-14 问题4:fail() 清空后 ops.json 是
|
||||
// 2 字节的 "[]",旧判空 length()==0 挡不住 → REPLACE_EXISTING 会用空壳
|
||||
// 覆盖掉唯一一份好备份,管理员就只能手动逐个恢复 OP)
|
||||
if (!src.exists() || isDegenerateOps(src)) {
|
||||
if (dst.exists() && dst.length() > 0) {
|
||||
plugin.getLogger().info("[OP白名单] 原版 ops.json 实质为空(空壳),"
|
||||
+ "已有历史备份原样保留: OP白名单/" + NATIVE_OPS_BACKUP);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Files.copy(src.toPath(), dst.toPath(),
|
||||
@@ -399,6 +446,19 @@ public class OpWhiteListManager implements Listener {
|
||||
}
|
||||
}
|
||||
|
||||
/** ops.json 是否实质为空(不存在 / 空白 / 只有 "[]" 空壳)——空壳绝不允许覆盖历史备份 */
|
||||
private static boolean isDegenerateOps(File f) {
|
||||
try {
|
||||
if (!f.exists() || f.length() == 0) {
|
||||
return true;
|
||||
}
|
||||
String s = new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8).trim();
|
||||
return s.isEmpty() || s.equals("[]");
|
||||
} catch (Exception e) {
|
||||
return false; // 读不了 → 当作有内容,走正常备份流程(失败则按铁律不卸载)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 从留底备份复原原版 OP(返回复原到的玩家名,供 adoptToWhitelist 写回白名单)。
|
||||
*
|
||||
@@ -733,6 +793,12 @@ public class OpWhiteListManager implements Listener {
|
||||
+ String.join(", ", justRestored));
|
||||
}
|
||||
// 1) 非法持有扫描:持 OP 但不在白名单 → 撤销 + 报警
|
||||
// ★ 问题4 铁律(2026-10-14 用户原话):「先备份原生 ops.json 到插件自己的
|
||||
// json 文件夹下面来,再卸载删除」。本循环是 fail() 之外唯一的自动撤销入口
|
||||
// (配置自愈后的全量接管也走这里)—— 此前该路径一次备份都没做过,
|
||||
// 用户没提前备份就只能手动逐个恢复。备份失败 → 数据第一优先级,
|
||||
// 本轮中止 apply(不撤也不授),下轮自动重试。
|
||||
boolean opsBackedUp = false;
|
||||
for (OfflinePlayer op : new ArrayList<>(Bukkit.getOperators())) {
|
||||
String n = op.getName();
|
||||
if (n == null || n.isEmpty() || !op.isOp()) {
|
||||
@@ -744,10 +810,19 @@ public class OpWhiteListManager implements Listener {
|
||||
if (justRestored.contains(n.toLowerCase(Locale.ROOT))) {
|
||||
continue;
|
||||
}
|
||||
if (!opsBackedUp) {
|
||||
if (!backupNativeOps()) {
|
||||
plugin.getLogger().warning("[OP白名单] 撤销前备份原版 ops.json 失败 → "
|
||||
+ "本轮【跳过全部撤销】(数据第一优先级),下轮自动重试");
|
||||
return;
|
||||
}
|
||||
opsBackedUp = true;
|
||||
}
|
||||
setOpByName(n, false);
|
||||
warnIllegal(n, now, describeIllegal(n));
|
||||
// 卸任提示当事人(在线直发,离线暂存)
|
||||
notifyOpChange(n, false, "自动撤销(不在白名单:" + describeIllegal(n) + ")");
|
||||
// ★ 问题5(2026-10-14):玩家侧最小化信息披露 —— 只说结果,
|
||||
// 不点名白名单/判定细节(细节只进控制台 warnIllegal 日志)
|
||||
notifyAutoRevoked(n);
|
||||
}
|
||||
// 2) 白名单内玩家补授(已是 OP 的跳过)
|
||||
// ★ 2026-10-08 现场修复:原实现只遍历【在线】玩家,导致离线授权人
|
||||
@@ -1170,7 +1245,8 @@ public class OpWhiteListManager implements Listener {
|
||||
saveConfig();
|
||||
if (isOpNow(name)) {
|
||||
setOpByName(name, false);
|
||||
notifyOpChange(name, false, "临时OP被扣减至到期,已撤销");
|
||||
// ★ 问题6:旧文案与模板拼成病句「你的 临时OP被扣减至到期,已撤销 已撤销」
|
||||
notifyAutoRevoked(name);
|
||||
}
|
||||
sender.sendMessage("§a扣减后 " + name + " 的临时OP已到期,条目已移除,OP 已撤销");
|
||||
} else {
|
||||
@@ -1194,7 +1270,9 @@ public class OpWhiteListManager implements Listener {
|
||||
boolean wasOp = isOpNow(name);
|
||||
setOpByName(name, false);
|
||||
if (wasOp) {
|
||||
notifyOpChange(name, false, inTimed ? "临时OP被移除" : "长期OP被移除");
|
||||
// ★ 问题6(2026-10-14):旧文案「你的 长期OP被移除 已撤销」是病句,
|
||||
// 改走固定文案「管理员已撤销你的OP」(最小化信息披露)
|
||||
notifyAutoRevoked(name);
|
||||
}
|
||||
lastWarn.remove(name.toLowerCase(Locale.ROOT));
|
||||
if (!inPerm && !inTimed && !wasOp) {
|
||||
@@ -1275,10 +1353,22 @@ public class OpWhiteListManager implements Listener {
|
||||
/** 提示当事人 OP 上任/卸任。在线直发;离线暂存 pendingMsg,上线时补发。 */
|
||||
public void notifyOpChange(String name, boolean isGrant, String what) {
|
||||
if (name == null || name.isEmpty()) return;
|
||||
Player p = Bukkit.getPlayerExact(name);
|
||||
String msg = isGrant
|
||||
? "§a[OP白名单] 你已获得 §f" + what + "§a。"
|
||||
: "§c[OP白名单] 你的 §f" + what + " §c已撤销,OP 权限已收回。";
|
||||
notifyMsg(name, msg);
|
||||
}
|
||||
|
||||
/** ★ 问题5(2026-10-14):自动撤销的玩家侧固定文案 —— 最小化信息披露,
|
||||
* 只说结果「管理员已撤销你的OP」;白名单/判定细节只留控制台 warnIllegal 日志。 */
|
||||
public void notifyAutoRevoked(String name) {
|
||||
if (name == null || name.isEmpty()) return;
|
||||
notifyMsg(name, "§c[OP白名单] §f管理员已撤销你的OP");
|
||||
}
|
||||
|
||||
/** 提示投递:在线直发,离线暂存(每人最多 2 条,上线由 flushPendingMsgs 投递) */
|
||||
private void notifyMsg(String name, String msg) {
|
||||
Player p = Bukkit.getPlayerExact(name);
|
||||
if (p != null) {
|
||||
p.sendMessage(msg);
|
||||
} else {
|
||||
@@ -1303,16 +1393,18 @@ public class OpWhiteListManager implements Listener {
|
||||
|
||||
// ================= 注册检查 =================
|
||||
|
||||
/** 查被授权人是不是本服务器注册用户(DatabaseManager.userExistsIgnoreCase)。
|
||||
* 查不到 / db 未就绪 一律返回 false(保守拒绝,让运维改 json 兜底)。 */
|
||||
/** 查被授权人是不是本服务器注册用户(DatabaseManager.userExistsStrict 严格版)。
|
||||
* 查不到 / db 未就绪 / SQL 异常一律返回 false(保守拒绝,让运维改 json 兜底)。
|
||||
* 2026-10-11:userExistsIgnoreCase 在 SQL 异常时会放行(为封禁防护设计),
|
||||
* 对 opwl 授权是漏洞——DB 抖动时未注册玩家也能拿到临时 OP,故换严格版。 */
|
||||
private boolean isRegistered(String name) {
|
||||
if (name == null || name.isEmpty()) return false;
|
||||
DatabaseManager db = plugin.getDb();
|
||||
if (db == null) return false;
|
||||
try {
|
||||
return db.userExistsIgnoreCase(name);
|
||||
return db.userExistsStrict(name);
|
||||
} catch (Throwable t) {
|
||||
plugin.getLogger().warning("[OP白名单] 注册检查异常: " + t.getMessage());
|
||||
plugin.getLogger().warning("[OP白名单] 注册检查异常(拒绝): " + t.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1425,6 +1517,6 @@ public class OpWhiteListManager implements Listener {
|
||||
|
||||
/** 日志里去掉颜色符号,避免控制台刷屏乱码 */
|
||||
private static String stripColor(String s) {
|
||||
return s == null ? "" : s.replaceAll("(?i)&[0-9a-k]", "").replaceAll("\u00a7[0-9a-k]", "");
|
||||
return s == null ? "" : s.replaceAll("(?i)&[0-9a-k]", "").replaceAll("§[0-9a-k]", "");
|
||||
}
|
||||
}
|
||||
@@ -512,8 +512,13 @@ public class PVPArenaManager implements Listener {
|
||||
if (p == null) return;
|
||||
String name = p.getName();
|
||||
// 仅将离场玩家个人记分板切回主世界(其战绩行仍保留在PVP记分板供其余玩家查看)
|
||||
// ★ 第35批:优先归还公告板,避免玩家看到空白侧边栏
|
||||
if (p.isOnline()) {
|
||||
p.setScoreboard(Bukkit.getScoreboardManager().getMainScoreboard());
|
||||
if (plugin.getAnnouncementBoard() != null) {
|
||||
plugin.getAnnouncementBoard().applyTo(p, true);
|
||||
} else {
|
||||
p.setScoreboard(Bukkit.getScoreboardManager().getMainScoreboard());
|
||||
}
|
||||
}
|
||||
// 世界未被销毁 → 保留所有参与者战绩,仅刷新榜面(含已离开玩家)
|
||||
if (pvpScoreboard != null) {
|
||||
@@ -1800,7 +1805,7 @@ public class PVPArenaManager implements Listener {
|
||||
killerSession);
|
||||
}
|
||||
|
||||
((Player) killer).sendMessage("\u00a7a\u00a7l击杀 " + vName + "\u00a7a 当前击杀: " + pvpKills.get(kName));
|
||||
((Player) killer).sendMessage("§a§l击杀 " + vName + "§a 当前击杀: " + pvpKills.get(kName));
|
||||
}
|
||||
pvpDeaths.put(player.getName(), pvpDeaths.getOrDefault(player.getName(), 0) + 1);
|
||||
// ★ 非击杀死亡也持久化(如摔死/溺水)
|
||||
|
||||
@@ -417,6 +417,16 @@ public class PVPManager implements Listener {
|
||||
return regions.keySet();
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第35批:玩家当前是否在某个 PVP 区域内。
|
||||
* 公告计分板据此避让 —— PVP 区域/竞技场内的侧边栏归 PVP 模块管,
|
||||
* 玩家离开 PVP 区域后由公告板接管回去。
|
||||
*/
|
||||
public boolean isInPVPRegion(String playerName) {
|
||||
if (playerName == null) return false;
|
||||
return inRegion.get(playerName) != null;
|
||||
}
|
||||
|
||||
// ===== 加载 =====
|
||||
|
||||
private void loadRegions() {
|
||||
@@ -1017,6 +1027,14 @@ public class PVPManager implements Listener {
|
||||
|
||||
private void removeScoreboard(
|
||||
Player p) {
|
||||
// ★ 第35批:PVP 区域结束后把侧边栏还给公告板
|
||||
// (原实现塞一个空计分板,玩家看到空白侧边栏)
|
||||
// force=true:此时 inRegion 已移除,强制归还公告板
|
||||
if (plugin.getAnnouncementBoard() != null) {
|
||||
plugin.getAnnouncementBoard()
|
||||
.applyTo(p, true);
|
||||
return;
|
||||
}
|
||||
ScoreboardManager mgr =
|
||||
Bukkit.getScoreboardManager();
|
||||
p.setScoreboard(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -135,7 +135,7 @@ public class SnManager implements Listener {
|
||||
/** 作废实物拦截提示限流(玩家名 -> 上次提示时间) */
|
||||
private final Map<String, Long> deadMsgAt = new ConcurrentHashMap<>();
|
||||
|
||||
/** PHP 命令执行结果缓冲(id + \u0001 + result),由 tickSync 回传 */
|
||||
/** PHP 命令执行结果缓冲(id + U+0001 分隔符 + result),由 tickSync 回传 */
|
||||
private final List<String> ackBuf = new ArrayList<String>();
|
||||
|
||||
/** 目录快照节流计数(每 3 轮 tickSync 推一次全量) */
|
||||
@@ -1999,7 +1999,7 @@ public class SnManager implements Listener {
|
||||
if (acks != null) {
|
||||
List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
|
||||
for (String a : acks) {
|
||||
int p = a.indexOf('\u0001');
|
||||
int p = a.indexOf(UnicodeChars.SEPARATOR);
|
||||
if (p <= 0) continue;
|
||||
Map<String, Object> m = new LinkedHashMap<String, Object>();
|
||||
m.put("id", Long.parseLong(a.substring(0, p)));
|
||||
@@ -2187,7 +2187,7 @@ public class SnManager implements Listener {
|
||||
}
|
||||
if (ackId >= 0 && result != null) {
|
||||
synchronized (ackBuf) {
|
||||
ackBuf.add(ackId + "\u0001" + result);
|
||||
ackBuf.add(ackId + UnicodeChars.SEPARATOR_TEXT + result);
|
||||
}
|
||||
}
|
||||
return result == null ? "" : result;
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import java.io.File;
|
||||
import java.sql.*;
|
||||
|
||||
/**
|
||||
* 交易记录数据库(2026-10-11 新增)。
|
||||
*
|
||||
* <p>独立 db(交易记录/shop_trades.db),专门存放三类数据:
|
||||
* <ul>
|
||||
* <li>{@code normal_shop_orders} —— 正常商店的交易订单(直购 + 购物车结账)</li>
|
||||
* <li>{@code secondhand_orders} —— 二手商店的交易订单(含手续费与实际到手)</li>
|
||||
* <li>{@code player_selling_prefs}—— 玩家的出售偏好(奸商模式=买方出 / 正常模式=卖方出)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>铁律:db 文件创建即指向子目录,绝不人工移动/改名(WAL 三件套同进同退)。
|
||||
*/
|
||||
public class TradeLogManager {
|
||||
|
||||
private final Main plugin;
|
||||
private Connection db;
|
||||
private boolean ready = false;
|
||||
|
||||
public TradeLogManager(Main plugin) {
|
||||
this.plugin = plugin;
|
||||
init();
|
||||
}
|
||||
|
||||
private synchronized void init() {
|
||||
try {
|
||||
Class.forName("org.sqlite.JDBC");
|
||||
// ★ 创建即归位到子目录(2026-10-11 db 铁律:路径直接指向目标目录)
|
||||
File dir = new File(plugin.getDataFolder(), "交易记录");
|
||||
dir.mkdirs();
|
||||
File dbFile = new File(dir, "shop_trades.db");
|
||||
db = DriverManager.getConnection(
|
||||
"jdbc:sqlite:" + dbFile.getAbsolutePath());
|
||||
try (Statement st = db.createStatement()) {
|
||||
st.execute("PRAGMA journal_mode=WAL");
|
||||
|
||||
st.execute("CREATE TABLE IF NOT EXISTS normal_shop_orders ("
|
||||
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
+ "buyer TEXT NOT NULL,"
|
||||
+ "item_name TEXT NOT NULL,"
|
||||
+ "amount INTEGER NOT NULL,"
|
||||
+ "unit_price INTEGER NOT NULL,"
|
||||
+ "total_bonds INTEGER NOT NULL,"
|
||||
+ "discount_pct INTEGER DEFAULT 0,"
|
||||
+ "source TEXT DEFAULT 'direct',"
|
||||
+ "created_at INTEGER NOT NULL)");
|
||||
|
||||
st.execute("CREATE TABLE IF NOT EXISTS secondhand_orders ("
|
||||
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
+ "listing_id INTEGER NOT NULL,"
|
||||
+ "buyer TEXT NOT NULL,"
|
||||
+ "seller TEXT NOT NULL,"
|
||||
+ "item_name TEXT NOT NULL,"
|
||||
+ "amount INTEGER NOT NULL,"
|
||||
+ "price INTEGER NOT NULL,"
|
||||
+ "fee INTEGER DEFAULT 0,"
|
||||
+ "fee_mode TEXT DEFAULT 'SELLER',"
|
||||
+ "buyer_paid INTEGER NOT NULL,"
|
||||
+ "seller_received INTEGER NOT NULL,"
|
||||
+ "created_at INTEGER NOT NULL)");
|
||||
|
||||
st.execute("CREATE TABLE IF NOT EXISTS player_selling_prefs ("
|
||||
+ "player_name TEXT PRIMARY KEY,"
|
||||
+ "fee_mode TEXT NOT NULL,"
|
||||
+ "updated_at INTEGER NOT NULL)");
|
||||
}
|
||||
ready = true;
|
||||
plugin.getLogger().info("[交易记录] shop_trades.db 初始化完成: "
|
||||
+ dbFile.getAbsolutePath());
|
||||
} catch (Exception e) {
|
||||
ready = false;
|
||||
plugin.getLogger().warning("[交易记录] 初始化失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 正常商店订单 ====================
|
||||
|
||||
/**
|
||||
* 记录一笔正常商店交易订单。
|
||||
*
|
||||
* @param source direct=直购 / cart=购物车结账
|
||||
*/
|
||||
public void recordNormalOrder(String buyer, String itemName, int amount,
|
||||
int unitPrice, int totalBonds,
|
||||
int discountPct, String source) {
|
||||
if (!ready) return;
|
||||
try (PreparedStatement ps = db.prepareStatement(
|
||||
"INSERT INTO normal_shop_orders "
|
||||
+ "(buyer, item_name, amount, unit_price, total_bonds, "
|
||||
+ "discount_pct, source, created_at) "
|
||||
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)")) {
|
||||
ps.setString(1, nvl(buyer));
|
||||
ps.setString(2, nvl(itemName));
|
||||
ps.setInt(3, amount);
|
||||
ps.setInt(4, unitPrice);
|
||||
ps.setInt(5, totalBonds);
|
||||
ps.setInt(6, discountPct);
|
||||
ps.setString(7, nvl(source));
|
||||
ps.setLong(8, System.currentTimeMillis());
|
||||
ps.executeUpdate();
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[交易记录] 正常订单写入失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 二手商店订单 ====================
|
||||
|
||||
/**
|
||||
* 记录一笔二手交易订单(含手续费与双方实际金额)。
|
||||
*
|
||||
* @param feeMode BUYER=奸商模式(买方承担手续费)/ SELLER=正常模式(卖方承担)
|
||||
*/
|
||||
public void recordSecondHandOrder(long listingId, String buyer, String seller,
|
||||
String itemName, int amount, int price,
|
||||
int fee, String feeMode,
|
||||
int buyerPaid, int sellerReceived) {
|
||||
if (!ready) return;
|
||||
try (PreparedStatement ps = db.prepareStatement(
|
||||
"INSERT INTO secondhand_orders "
|
||||
+ "(listing_id, buyer, seller, item_name, amount, price, "
|
||||
+ "fee, fee_mode, buyer_paid, seller_received, created_at) "
|
||||
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")) {
|
||||
ps.setLong(1, listingId);
|
||||
ps.setString(2, nvl(buyer));
|
||||
ps.setString(3, nvl(seller));
|
||||
ps.setString(4, nvl(itemName));
|
||||
ps.setInt(5, amount);
|
||||
ps.setInt(6, price);
|
||||
ps.setInt(7, fee);
|
||||
ps.setString(8, nvl(feeMode));
|
||||
ps.setInt(9, buyerPaid);
|
||||
ps.setInt(10, sellerReceived);
|
||||
ps.setLong(11, System.currentTimeMillis());
|
||||
ps.executeUpdate();
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[交易记录] 二手订单写入失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 玩家出售偏好 ====================
|
||||
|
||||
/** 取玩家出售偏好;无记录返回 null(由调用方回退全局配置)。 */
|
||||
public String getSellingPref(String player) {
|
||||
if (!ready || player == null) return null;
|
||||
try (PreparedStatement ps = db.prepareStatement(
|
||||
"SELECT fee_mode FROM player_selling_prefs WHERE player_name = ?")) {
|
||||
ps.setString(1, player);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) return rs.getString(1);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[交易记录] 读取偏好失败: " + e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 写入/覆盖玩家出售偏好(BUYER=奸商模式 / SELLER=正常模式)。 */
|
||||
public void setSellingPref(String player, String feeMode) {
|
||||
if (!ready || player == null || feeMode == null) return;
|
||||
try (PreparedStatement ps = db.prepareStatement(
|
||||
"INSERT OR REPLACE INTO player_selling_prefs "
|
||||
+ "(player_name, fee_mode, updated_at) VALUES (?, ?, ?)")) {
|
||||
ps.setString(1, player);
|
||||
ps.setString(2, feeMode);
|
||||
ps.setLong(3, System.currentTimeMillis());
|
||||
ps.executeUpdate();
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[交易记录] 写入偏好失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static String nvl(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
|
||||
public synchronized void close() {
|
||||
try {
|
||||
if (db != null) db.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package Sdf1_login;
|
||||
|
||||
/**
|
||||
* Unicode 特殊字符常量表。
|
||||
*
|
||||
* <p>背景:项目里原先大量使用 Unicode 转义字面量(写成 U+XXXX 形式),
|
||||
* 读代码的人必须逐个心算码位才能看懂(例如 U+00A7 其实是 MC 颜色码 §)。
|
||||
* 尤其不可见字符(零宽空格、BOM、全角空格…)直接写成字面量后
|
||||
* 在编辑器里根本"看不见",反而更难排查。</p>
|
||||
*
|
||||
* <p>因此本类把两类字符分开处理:</p>
|
||||
* <ul>
|
||||
* <li><b>可见字符</b>(§、:、全角数字、汉字等)→ 直接在业务代码里写字面量,
|
||||
* 一眼可读;</li>
|
||||
* <li><b>不可见字符</b>(零宽空格、BOM、全角空格、U+0001 分隔符等)→
|
||||
* 统一引用本类的具名常量,既可读又不会因"看不见"而误判。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>注意:正则表达式字符串里的 Unicode 转义属于<b>正则层</b>转义
|
||||
* (Java 字符串里存的是字面反斜杠+u,由正则引擎在运行期解释),
|
||||
* 改写会改变正则语义。本项目已把正则层里<b>可见</b>的转义全部改写成
|
||||
* 真实字符或本类的具名常量;<b>不可见</b>的转义则通过本类常量参与拼接。</p>
|
||||
*/
|
||||
public final class UnicodeChars {
|
||||
|
||||
private UnicodeChars() {
|
||||
}
|
||||
|
||||
// ==================== 不可见字符(必须用具名常量) ====================
|
||||
|
||||
/** U+0001 SOH —— SnManager 与 PHP 命令执行结果之间的分隔符 */
|
||||
public static final char SEPARATOR = '\u0001';
|
||||
|
||||
/** U+00A0 不间断空格(NO-BREAK SPACE) */
|
||||
public static final char NBSP = '\u00A0';
|
||||
|
||||
/** U+00AD 软连字符(SOFT HYPHEN) */
|
||||
public static final char SOFT_HYPHEN = '\u00AD';
|
||||
|
||||
/** U+2000 EN QUAD —— Unicode 各种空格区起点 */
|
||||
public static final char SPACE_U2000 = '\u2000';
|
||||
|
||||
/** U+200A HAIR SPACE —— Unicode 各种空格区终点 */
|
||||
public static final char SPACE_U200A = '\u200A';
|
||||
|
||||
/** U+200B 零宽空格(ZERO WIDTH SPACE) */
|
||||
public static final char ZERO_WIDTH_SPACE = '\u200B';
|
||||
|
||||
/** U+200C 零宽非连接符(ZERO WIDTH NON-JOINER) */
|
||||
public static final char ZERO_WIDTH_NON_JOINER = '\u200C';
|
||||
|
||||
/** U+200D 零宽连接符(ZERO WIDTH JOINER) */
|
||||
public static final char ZERO_WIDTH_JOINER = '\u200D';
|
||||
|
||||
/** U+200E 从左至右标记(LTR MARK) */
|
||||
public static final char LTR_MARK = '\u200E';
|
||||
|
||||
/** U+200F 从右至左标记(RTL MARK) */
|
||||
public static final char RTL_MARK = '\u200F';
|
||||
|
||||
/** U+2028 行分隔符(LINE SEPARATOR) */
|
||||
public static final char LINE_SEPARATOR = '\u2028';
|
||||
|
||||
/** U+2029 段分隔符(PARAGRAPH SEPARATOR) */
|
||||
public static final char PARAGRAPH_SEPARATOR = '\u2029';
|
||||
|
||||
/** U+2060 字连接符(WORD JOINER) */
|
||||
public static final char WORD_JOINER = '\u2060';
|
||||
|
||||
/** U+3000 表意空格(IDEOGRAPHIC SPACE,即"全角空格") */
|
||||
public static final char IDEOGRAPHIC_SPACE = '\u3000';
|
||||
|
||||
/** U+FEFF 零宽不换行空格 / BOM(ZERO WIDTH NO-BREAK SPACE) */
|
||||
public static final char BOM = '\uFEFF';
|
||||
|
||||
// ==================== 区间常量(不可见/边界判定用) ====================
|
||||
|
||||
/** CJK 基本汉字区起点 U+4E00(一) */
|
||||
public static final char CJK_START = '一';
|
||||
|
||||
/** CJK 基本汉字区终点 U+9FFF */
|
||||
public static final char CJK_END = '鿿';
|
||||
|
||||
/** CJK 兼容汉字常用终点 U+9FA5(龥) */
|
||||
public static final char CJK_COMPAT_END = '龥';
|
||||
|
||||
/** CJK 扩展 A 区起点 U+3400(㐀) */
|
||||
public static final char CJK_EXT_A_START = '㐀';
|
||||
|
||||
/** CJK 扩展 A 区终点 U+4DBF(䶿) */
|
||||
public static final char CJK_EXT_A_END = '䶿';
|
||||
|
||||
/** 全角 ASCII 起点 U+FF01(!) */
|
||||
public static final char FULLWIDTH_START = '!';
|
||||
|
||||
/** 全角 ASCII 终点 U+FF5E(~) */
|
||||
public static final char FULLWIDTH_END = '~';
|
||||
|
||||
/** 全角数字 0 U+FF10 */
|
||||
public static final char FULLWIDTH_ZERO = '0';
|
||||
|
||||
/** 全角数字 9 U+FF19 */
|
||||
public static final char FULLWIDTH_NINE = '9';
|
||||
|
||||
/** 全角大写 A U+FF21 */
|
||||
public static final char FULLWIDTH_A_UPPER = 'A';
|
||||
|
||||
/** 全角大写 Z U+FF3A */
|
||||
public static final char FULLWIDTH_Z_UPPER = 'Z';
|
||||
|
||||
/** 全角小写 a U+FF41 */
|
||||
public static final char FULLWIDTH_A_LOWER = 'a';
|
||||
|
||||
/** 全角小写 z U+FF5A */
|
||||
public static final char FULLWIDTH_Z_LOWER = 'z';
|
||||
|
||||
/** 全角左括号 U+FF08 */
|
||||
public static final char FULLWIDTH_LPAREN = '(';
|
||||
|
||||
/** 全角右括号 U+FF09 */
|
||||
public static final char FULLWIDTH_RPAREN = ')';
|
||||
|
||||
/** 全角井号 U+FF03 */
|
||||
public static final char FULLWIDTH_HASH = '#';
|
||||
|
||||
/** 全角逗号 U+FF0C */
|
||||
public static final char FULLWIDTH_COMMA = ',';
|
||||
|
||||
/** 全角分号 U+FF1B */
|
||||
public static final char FULLWIDTH_SEMICOLON = ';';
|
||||
|
||||
/** 全角冒号 U+FF1A */
|
||||
public static final char FULLWIDTH_COLON = ':';
|
||||
|
||||
/** 全角句点 U+FF0E */
|
||||
public static final char FULLWIDTH_STOP = '.';
|
||||
|
||||
/** 全角波浪号 U+FF5E */
|
||||
public static final char FULLWIDTH_TILDE = '~';
|
||||
|
||||
/** 半角片假名中点 U+FF65 */
|
||||
public static final char HALFWIDTH_KATAKANA_MIDDLE_DOT = '・';
|
||||
|
||||
/** 小型句点 U+FE52 */
|
||||
public static final char SMALL_FULL_STOP = '﹒';
|
||||
|
||||
/** 二点引导符 U+2025 */
|
||||
public static final char TWO_DOT_LEADER = '‥';
|
||||
|
||||
/** 水平省略号 U+2026 */
|
||||
public static final char HORIZONTAL_ELLIPSIS = '…';
|
||||
|
||||
/** 中点 U+00B7 */
|
||||
public static final char MIDDLE_DOT = '·';
|
||||
|
||||
/** 项目符号 U+2022 */
|
||||
public static final char BULLET = '•';
|
||||
|
||||
/** 表意句点 U+3002 */
|
||||
public static final char IDEOGRAPHIC_STOP = '。';
|
||||
|
||||
/** 表意逗号 U+3001 */
|
||||
public static final char IDEOGRAPHIC_COMMA = '、';
|
||||
|
||||
/** 表意数字零 U+3007(〇) */
|
||||
public static final char IDEOGRAPHIC_ZERO = '〇';
|
||||
|
||||
/** 左黑 lenticular 方括号 U+3010(【) */
|
||||
public static final char LEFT_BLACK_LENTICULAR = '【';
|
||||
|
||||
/** 右黑 lenticular 方括号 U+3011(】) */
|
||||
public static final char RIGHT_BLACK_LENTICULAR = '】';
|
||||
|
||||
/** 左 tortoise shell 括号 U+3014(〔) */
|
||||
public static final char LEFT_TORTOISE_SHELL = '〔';
|
||||
|
||||
/** 右 tortoise shell 括号 U+3015(〕) */
|
||||
public static final char RIGHT_TORTOISE_SHELL = '〕';
|
||||
|
||||
/** 左直角引号 U+300C(「) */
|
||||
public static final char LEFT_CORNER_BRACKET = '「';
|
||||
|
||||
/** 右直角引号 U+300D」(」) */
|
||||
public static final char RIGHT_CORNER_BRACKET = '」';
|
||||
|
||||
/** 左白直角引号 U+300E(『) */
|
||||
public static final char LEFT_WHITE_CORNER_BRACKET = '『';
|
||||
|
||||
/** 右白直角引号 U+300F(』) */
|
||||
public static final char RIGHT_WHITE_CORNER_BRACKET = '』';
|
||||
|
||||
// ==================== 常用字符串常量 ====================
|
||||
|
||||
/** MC 颜色码前缀 § */
|
||||
public static final String COLOR_SIGN = "§";
|
||||
|
||||
/** 全角冒号 ":" */
|
||||
public static final String WIDE_COLON = ":";
|
||||
|
||||
// ==================== 字符串版(用于拼接场景) ====================
|
||||
// 注意:char 常量参与字符串拼接时若两侧都是 char 会被当成数值加法,
|
||||
// 因此需要拼接的不可见字符一律用这里的 String 版常量。
|
||||
|
||||
/** U+FEFF BOM 的字符串版 */
|
||||
public static final String BOM_TEXT = "\uFEFF";
|
||||
|
||||
/** U+0001 分隔符的字符串版 */
|
||||
public static final String SEPARATOR_TEXT = "\u0001";
|
||||
|
||||
/** U+3000 全角空格的字符串版 */
|
||||
public static final String IDEOGRAPHIC_SPACE_TEXT = "\u3000";
|
||||
|
||||
/**
|
||||
* 零宽/不可见字符集合(聊天变形 URL 归一前剔除用)。
|
||||
* 含:零宽空格、零宽非连接、零宽连接、字连接符、BOM、软连字符。
|
||||
*/
|
||||
public static final String INVISIBLE_CHARS = "\u200B\u200C\u200D\u2060\uFEFF\u00AD";
|
||||
|
||||
/** U+200B 零宽空格的字符串版 */
|
||||
public static final String ZERO_WIDTH_SPACE_TEXT = "\u200B";
|
||||
|
||||
/** U+200D 零宽连接符的字符串版 */
|
||||
public static final String ZERO_WIDTH_JOINER_TEXT = "\u200D";
|
||||
|
||||
/** U+2060 字连接符的字符串版 */
|
||||
public static final String WORD_JOINER_TEXT = "\u2060";
|
||||
|
||||
/** U+00A0 不间断空格的字符串版 */
|
||||
public static final String NBSP_TEXT = "\u00A0";
|
||||
|
||||
/**
|
||||
* 需要剔除的不可见字符(正则字符类形式,净化玩家输入用)。
|
||||
* 含:零宽空格~零宽连接(区间)、字连接符、BOM、不间断空格、全角空格。
|
||||
* <p>这里用具名常量拼接,而不是转义写法——这样源码里能直接看出
|
||||
* 每个字符的用途,读代码的人不必去心算码位。</p>
|
||||
*/
|
||||
public static final String INVISIBLE_STRIP_CLASS =
|
||||
"[" + ZERO_WIDTH_SPACE_TEXT + "-" + ZERO_WIDTH_JOINER_TEXT
|
||||
+ WORD_JOINER_TEXT + BOM_TEXT + NBSP_TEXT + IDEOGRAPHIC_SPACE_TEXT + "]";
|
||||
}
|
||||
@@ -56,7 +56,10 @@ public class UserGroupManager {
|
||||
+ "default_perms TEXT DEFAULT '{}'," // JSON: 默认deny_*标志
|
||||
+ "is_permanent INTEGER DEFAULT 1," // 保留兼容
|
||||
+ "duration_minutes INTEGER DEFAULT 0," // 保留兼容
|
||||
+ "max_effect_level INTEGER DEFAULT 256" // 药效等级上限,<=0=按原版256
|
||||
+ "max_effect_level INTEGER DEFAULT 256," // 药效等级上限,<=0=按原版256
|
||||
// === 二手商店相关(2026-10-11)===
|
||||
+ "shop_max_listings INTEGER DEFAULT -1," // -1 = 使用全局值
|
||||
+ "shop_min_price INTEGER DEFAULT -1" // -1 = 使用全局值
|
||||
+ ")");
|
||||
|
||||
// 玩家↔用户组关联
|
||||
@@ -84,6 +87,9 @@ public class UserGroupManager {
|
||||
try { st.executeUpdate("ALTER TABLE user_group_config ADD COLUMN max_effects INTEGER DEFAULT 5"); } catch (Exception ignored) {}
|
||||
// ★ 药效等级上限(2026-10-04 任务4):默认256=原版最大药效强度,<=0=按原版
|
||||
try { st.executeUpdate("ALTER TABLE user_group_config ADD COLUMN max_effect_level INTEGER DEFAULT 256"); } catch (Exception ignored) {}
|
||||
// ★ 二手商店(2026-10-11):上架数量/最低售价,-1=跟随全局
|
||||
try { st.executeUpdate("ALTER TABLE user_group_config ADD COLUMN shop_max_listings INTEGER DEFAULT -1"); } catch (Exception ignored) {}
|
||||
try { st.executeUpdate("ALTER TABLE user_group_config ADD COLUMN shop_min_price INTEGER DEFAULT -1"); } catch (Exception ignored) {}
|
||||
// duration_minutes已有(旧列),复用
|
||||
|
||||
st.close();
|
||||
@@ -118,6 +124,10 @@ public class UserGroupManager {
|
||||
if (rs.wasNull()) cfg.maxEffects = 5;
|
||||
cfg.maxEffectLevel = rs.getInt("max_effect_level");
|
||||
if (rs.wasNull()) cfg.maxEffectLevel = UserGroupManager.DEFAULT_MAX_EFFECT_LEVEL;
|
||||
cfg.shopMaxListings = rs.getInt("shop_max_listings");
|
||||
if (rs.wasNull()) cfg.shopMaxListings = -1;
|
||||
cfg.shopMinPrice = rs.getInt("shop_min_price");
|
||||
if (rs.wasNull()) cfg.shopMinPrice = -1;
|
||||
if (cfg.name != null && !cfg.name.isEmpty()) {
|
||||
groupConfigs.put(cfg.name, cfg);
|
||||
}
|
||||
@@ -139,8 +149,8 @@ public class UserGroupManager {
|
||||
+ "(group_name, display_name, display_color,"
|
||||
+ " priority, land_price_per_sqm, max_lands, default_perms,"
|
||||
+ " home_limit, join_price, auto_renew, renew_price, duration_minutes,"
|
||||
+ " max_effects, max_effect_level)"
|
||||
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
|
||||
+ " max_effects, max_effect_level, shop_max_listings, shop_min_price)"
|
||||
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
|
||||
ps.setString(1, cfg.name);
|
||||
ps.setString(2, cfg.displayName);
|
||||
ps.setString(3, cfg.displayColor);
|
||||
@@ -155,6 +165,8 @@ public class UserGroupManager {
|
||||
ps.setLong(12, cfg.durationMinutes);
|
||||
ps.setInt(13, cfg.maxEffects);
|
||||
ps.setInt(14, cfg.maxEffectLevel);
|
||||
ps.setInt(15, cfg.shopMaxListings);
|
||||
ps.setInt(16, cfg.shopMinPrice);
|
||||
ps.executeUpdate();
|
||||
ps.close();
|
||||
} catch (SQLException e) {
|
||||
@@ -975,6 +987,28 @@ public class UserGroupManager {
|
||||
return cfg.maxLands >= 0 ? cfg.maxLands : globalDefault;
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 二手商店:获取玩家的可上架数量上限(2026-10-11)
|
||||
* 组配置 >=0 用组值(0=禁止上架),-1=跟随全局
|
||||
*/
|
||||
public int getPlayerShopMaxListings(String player, int globalDefault) {
|
||||
if (player == null || player.isEmpty()) return globalDefault;
|
||||
UserGroupConfig cfg = getHighestGroup(player);
|
||||
if (cfg == null) return globalDefault;
|
||||
return cfg.shopMaxListings >= 0 ? cfg.shopMaxListings : globalDefault;
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 二手商店:获取玩家的最低售价(2026-10-11)
|
||||
* 组配置 >=0 用组值,-1=跟随全局
|
||||
*/
|
||||
public int getPlayerShopMinPrice(String player, int globalDefault) {
|
||||
if (player == null || player.isEmpty()) return globalDefault;
|
||||
UserGroupConfig cfg = getHighestGroup(player);
|
||||
if (cfg == null) return globalDefault;
|
||||
return cfg.shopMinPrice >= 0 ? cfg.shopMinPrice : globalDefault;
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 领地增益效果数量上限(2026-10-04 任务3)
|
||||
* 普通玩家(无用户组)走 globalDefault=5;用户组按组配置;
|
||||
@@ -1202,6 +1236,8 @@ public class UserGroupManager {
|
||||
public int maxLands = -1; // -1 = 使用全局
|
||||
public int maxEffects = 5; // 领地增益效果上限,<=0=不限
|
||||
public int maxEffectLevel = DEFAULT_MAX_EFFECT_LEVEL; // 药效等级上限,<=0=按原版256
|
||||
public int shopMaxListings = -1; // 二手商店上架数量上限,-1=跟随全局
|
||||
public int shopMinPrice = -1; // 二手商店最低售价,-1=跟随全局
|
||||
public String defaultPerms = "{}"; // JSON
|
||||
// Home相关
|
||||
public int homeLimit = 0; // 0=跟随全局, >0=独立限制, -1=无限
|
||||
|
||||
@@ -159,6 +159,16 @@ public class WebManager {
|
||||
|
||||
settingsFileStamp = stamp;
|
||||
|
||||
// ★ 第31批(#204):web 感知到 插件设置.txt 变化 → 拉起整份文件重载
|
||||
// (垃圾站等其他模块的配置字段;ConfigManager 内部比对,无变化静默)
|
||||
// ★ 第42批:重载成功后把垃圾站启停应用到运行态(否则只改字段不生效)
|
||||
if (plugin.getConfig2() != null
|
||||
&& plugin.getConfig2().reloadSettingsIfChanged()) {
|
||||
if (plugin.getGarbage() != null) {
|
||||
plugin.getGarbage().applyEnabledState();
|
||||
}
|
||||
}
|
||||
|
||||
if (!urlChanged && !secretChanged && !enableChanged && !othersChanged) {
|
||||
return; // 文件动了但 Web 段没变(如改了别的模块)→ 安静走人
|
||||
}
|
||||
@@ -321,8 +331,11 @@ public class WebManager {
|
||||
private String lastBondBalanceHash = "";
|
||||
private String lastLandDataHash = "";
|
||||
private String lastBansHash = "";
|
||||
private boolean bansCreatedPushed = false; // ★ 第十九批:进程内是否已把 created/起点推给PHP过一次
|
||||
private long lastPushedCutoff = -1; // 上次推送时的播报起点(变了就再强制推一轮)
|
||||
private String lastAdminsHash = "";
|
||||
private String lastUserRegistrationHash = "";
|
||||
private String lastBlacklistHash = ""; // ★ 第32批:拉黑名单同步快照
|
||||
|
||||
// 嵌入式HTTP服务器(接收PHP回调)
|
||||
private java.net.ServerSocket callbackServer;
|
||||
@@ -3081,6 +3094,12 @@ public class WebManager {
|
||||
private static final int SHOP_STOCK_POLL_FAIL_THRESHOLD = 10;
|
||||
/** 防重入锁:pullShopStock执行期间不重复拉取 */
|
||||
private volatile boolean shopStockPulling = false;
|
||||
/** ★ 库存双向同步(2026-10-10):本地库存快照 id->stock(盘上持久化 shop_stock_snapshot.properties)。
|
||||
* 与 md 当前值比对得出本地未推送差异 -> push 回 PHP;pull 应用 PHP 值时若本地有未推送差异则跳过覆盖
|
||||
* (哪边有改动就对齐哪边,数据分叉以 Java 为主)。 */
|
||||
private final Map<String, Integer> shopStockSnapshot = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private volatile boolean shopStockSnapshotLoaded = false;
|
||||
private volatile long lastShopStockPushLog = 0;
|
||||
|
||||
/**
|
||||
* 库存高频轮询:每15秒(±5)请求Web端库存变更检测接口
|
||||
@@ -3094,6 +3113,8 @@ public class WebManager {
|
||||
if (!initialSyncComplete) return;
|
||||
try {
|
||||
if (shopStockPulling) return;
|
||||
// ★ 双向同步:每轮 poll 先把本地库存差异推回 PHP(哪边有改动就对齐哪边)
|
||||
pushShopStockDiffs();
|
||||
if (shopStockPollFailCount >= SHOP_STOCK_POLL_FAIL_THRESHOLD) {
|
||||
if (shopStockPollFailCount % 12 != 0) {
|
||||
shopStockPollFailCount++;
|
||||
@@ -3174,6 +3195,8 @@ public class WebManager {
|
||||
*/
|
||||
private boolean pullShopStockSync() {
|
||||
try {
|
||||
// ★ 即时/兜底拉取路径也先推本地差异,保证 push 不依赖 poll 节奏
|
||||
pushShopStockDiffs();
|
||||
String urlStr = webBaseUrl + "/api/sync.php?action=pull_shop_stock&secret="
|
||||
+ java.net.URLEncoder.encode(secretKey, "UTF-8");
|
||||
|
||||
@@ -3320,7 +3343,14 @@ public class WebManager {
|
||||
// ★ 直接检查sdf1计分板CDK(bond.db只是钱包,不存CDK)
|
||||
String status = "not_found";
|
||||
int amount = 0;
|
||||
try {
|
||||
// ★ 第32批:被拉黑玩家的兑换请求 → 直接回 blacklisted,
|
||||
// 不进验证链、不加债券(PHP cdk.php 据此明确提示「你已被拉黑」)
|
||||
if (plugin.getBlacklist() != null
|
||||
&& plugin.getBlacklist().isBlacklisted(playerName)) {
|
||||
status = "blacklisted";
|
||||
plugin.getLogger().info("[CDK远程验证] " + cdkCode
|
||||
+ " 拉黑拦截 player=" + playerName);
|
||||
} else try {
|
||||
Object sdf1Plugin = Bukkit.getPluginManager().getPlugin("sdf1");
|
||||
if (sdf1Plugin != null) {
|
||||
plugin.getLogger().info("[CDK远程验证] 调用sdf1检查计分板: " + cdkCode);
|
||||
@@ -3425,7 +3455,13 @@ public class WebManager {
|
||||
String status = "not_found";
|
||||
int amount = 0;
|
||||
boolean transientFail = false; // 瞬时故障 -> 可重试
|
||||
try {
|
||||
// ★ 第32批:被拉黑玩家 → 直接回 blacklisted,不发起远程验证
|
||||
if (plugin.getBlacklist() != null
|
||||
&& plugin.getBlacklist().isBlacklisted(playerName)) {
|
||||
status = "blacklisted";
|
||||
plugin.getLogger().info("[CDK-Web验证] " + cdkCode
|
||||
+ " 拉黑拦截 player=" + playerName);
|
||||
} else try {
|
||||
String validateUrl = webBaseUrl + "/api/sync.php?action=check_cdk_exists&secret="
|
||||
+ java.net.URLEncoder.encode(secretKey, "UTF-8")
|
||||
+ "&code=" + java.net.URLEncoder.encode(cdkCode, "UTF-8");
|
||||
@@ -3958,6 +3994,8 @@ public class WebManager {
|
||||
* 解码JSON中的 Unicode转义序列
|
||||
*/
|
||||
private static String decodeUnicodeEscapes(String str) {
|
||||
// 这里的 "\\u" 是「反斜杠 + u」两个字符(PHP 端 JSON 的转义标记),
|
||||
// 不是 Java 的 Unicode 转义——探测原始字符串里是否带转义标记。
|
||||
if (str == null || !str.contains("\\u")) return str;
|
||||
try {
|
||||
StringBuilder sb = new StringBuilder(str.length());
|
||||
@@ -4282,11 +4320,15 @@ public class WebManager {
|
||||
new String[]{},
|
||||
alignMap(), false, false));
|
||||
// —— 封禁名单(Web 后台加的封禁要拉回游戏内生效)——
|
||||
// ★ 29批:map 增加 created_time→created —— PHP check_alignment 的 cols 已带上
|
||||
// created_time,收回行才能携带真实封禁创建时间;hashcols 故意不含 created,
|
||||
// 指纹不变(防两端 created 精度/缺失差异恒 changed 死循环)。
|
||||
ALIGN_CFGS.put("bans", new AlignCfg("bans",
|
||||
new String[]{"target", "ban_type"},
|
||||
new String[]{"target", "ban_type", "reason", "source", "expire_time"},
|
||||
new String[]{"expire_time"},
|
||||
alignMap("ban_type", "type", "expire_time", "expire"), true, false));
|
||||
alignMap("ban_type", "type", "expire_time", "expire",
|
||||
"created_time", "created"), true, false));
|
||||
// —— 领地数据(PHP web_area_lands;游戏内 area_lands 为唯一权威)——
|
||||
// pull=false:Web 后台的领地改动不回流 Java(与改造前一致)。
|
||||
// pushOnExtra=true:PHP 多出的行(游戏里已删 / 后台自建)→ 触发一次整包推,
|
||||
@@ -4751,8 +4793,16 @@ public class WebManager {
|
||||
}
|
||||
// 真实签名:addBan(target, reason, expires, source)
|
||||
// Date 必须全限定——本文件同时 import 了 java.sql.* 与 java.util.*
|
||||
banList.addBan(target, reason,
|
||||
// ★ 29批:收回行现在携带 PHP 侧创建时间(check_alignment cols 已含
|
||||
// created_time→created)。addBan 默认 created=now,若不回填,
|
||||
// 生产封禁数据拷到测试服后「收回」一轮会把所有封禁的创建时间刷成
|
||||
// 当天 → 封禁播报全员乱叫(2026-10-22 用户实测)。
|
||||
org.bukkit.BanEntry<?> pulled = banList.addBan(target, reason,
|
||||
expire > 0 ? new java.util.Date(expire) : null, source);
|
||||
long created = longOf(row.get("created"));
|
||||
if (pulled != null && created > 0) {
|
||||
pulled.setCreated(new java.util.Date(created));
|
||||
}
|
||||
plugin.getLogger().info("[对账] 已收回 Web 端封禁: " + (isIp ? "IP " : "") + target);
|
||||
return true;
|
||||
}
|
||||
@@ -5262,6 +5312,8 @@ public class WebManager {
|
||||
// ★ 封禁名单和管理员列表独立于领地hash变化,必须每次都同步
|
||||
syncBans();
|
||||
syncAdmins();
|
||||
// ★ 第32批:拉黑名单同样每轮兜底(PHP重启丢数据时自愈)
|
||||
syncBlacklist();
|
||||
|
||||
// ★ 双向对账(多退少补):PHP 数据齐全就不推 —— 消除「重启必整包推」
|
||||
// (lastLandDataHash 是内存字段,重启清零,旧逻辑每次开服必整包一次)。
|
||||
@@ -5412,6 +5464,8 @@ public class WebManager {
|
||||
gs.append("\"max_lands\":").append(cfg.maxLands).append(",");
|
||||
gs.append("\"max_effects\":").append(cfg.maxEffects).append(",");
|
||||
gs.append("\"max_effect_level\":").append(cfg.maxEffectLevel).append(",");
|
||||
gs.append("\"shop_max_listings\":").append(cfg.shopMaxListings).append(",");
|
||||
gs.append("\"shop_min_price\":").append(cfg.shopMinPrice).append(",");
|
||||
gs.append("\"home_limit\":").append(cfg.homeLimit).append(",");
|
||||
gs.append("\"join_price\":").append(cfg.joinPrice).append(",");
|
||||
gs.append("\"auto_renew\":").append(cfg.autoRenew ? 1 : 0).append(",");
|
||||
@@ -5529,6 +5583,62 @@ public class WebManager {
|
||||
syncAdmins();
|
||||
}
|
||||
|
||||
// ★ 第32批(#208):拉黑名单同步到PHP(web_player_blacklist),
|
||||
// 供 PHP 后台识别被拉黑玩家并给出明确提示(登录/兑换码双链路)。
|
||||
|
||||
/**
|
||||
* 同步拉黑名单到PHP。参照 syncBans 模板:
|
||||
* hash 门控(无变化静默)→ POST → 回执 success 才提交 hash(防 404 写脏)。
|
||||
* PHP 侧 sync_blacklist 做全量覆盖,无需对账。
|
||||
*/
|
||||
private void syncBlacklist() {
|
||||
if (!enabled) return;
|
||||
try {
|
||||
BlacklistManager blm = plugin.getBlacklist();
|
||||
java.util.List<BlacklistManager.BlackEntry> list =
|
||||
blm != null ? blm.getActiveEntries() : null;
|
||||
StringBuilder sb = new StringBuilder("[");
|
||||
StringBuilder hb = new StringBuilder();
|
||||
boolean first = true;
|
||||
if (list != null) {
|
||||
for (BlacklistManager.BlackEntry e : list) {
|
||||
if (!first) sb.append(",");
|
||||
first = false;
|
||||
sb.append("{\"name\":\"").append(escapeJson(e.name))
|
||||
.append("\",\"time\":\"").append(escapeJson(e.timeText))
|
||||
.append("\",\"reason\":\"").append(escapeJson(e.reason))
|
||||
.append("\",\"expire\":").append(e.expiresMs)
|
||||
.append(",\"created\":").append(e.createdMs).append("}");
|
||||
hb.append(e.name).append(":").append(e.expiresMs)
|
||||
.append(":").append(e.reason).append("|");
|
||||
}
|
||||
}
|
||||
sb.append("]");
|
||||
String currentHash = hb.toString();
|
||||
if (currentHash.equals(lastBlacklistHash)) return; // 无变化静默
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("secret", secretKey);
|
||||
body.put("blacks", sb.toString());
|
||||
String resp = httpPost("api/sync.php?action=sync_blacklist", mapToJson(body));
|
||||
if (resp != null && resp.contains("\"success\":true")) {
|
||||
lastBlacklistHash = currentHash;
|
||||
plugin.getLogger().info("[防护-sync] 拉黑名单同步: "
|
||||
+ (list == null ? 0 : list.size()) + "人 → " + resp);
|
||||
} else {
|
||||
plugin.getLogger().warning("[防护-sync] 拉黑名单同步未确认成功,下轮重试(hash 不提交)");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[防护-sync] 拉黑名单同步异常: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** ★ 第32批:拉黑/解除时立即异步同步(不等定时轮询;内部 hash 门控兜底) */
|
||||
public void forceSyncBlacklist() {
|
||||
if (!enabled) return;
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, this::syncBlacklist);
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 同步封禁名单到PHP(供用户管理页面显示封禁状态)
|
||||
*/
|
||||
@@ -5552,10 +5662,13 @@ public class WebManager {
|
||||
String reason = entry.getReason() != null ? entry.getReason() : "";
|
||||
String source = entry.getSource() != null ? entry.getSource() : "";
|
||||
long expireDate = entry.getExpiration() != null ? entry.getExpiration().getTime() : 0;
|
||||
// ★ 第十九批:封禁创建时间(毫秒)一并下发 —— PHP 公示按它过滤插件上架前的历史封禁
|
||||
long createdDate = entry.getCreated() != null ? entry.getCreated().getTime() : 0;
|
||||
sb.append("{\"type\":\"name\",\"target\":\"").append(escapeJson(target))
|
||||
.append("\",\"reason\":\"").append(escapeJson(reason))
|
||||
.append("\",\"source\":\"").append(escapeJson(source))
|
||||
.append("\",\"expire\":").append(expireDate).append("}");
|
||||
.append("\",\"expire\":").append(expireDate)
|
||||
.append(",\"created\":").append(createdDate).append("}");
|
||||
hashBuilder.append("n:").append(target).append(":").append(reason).append(":").append(expireDate).append("|");
|
||||
}
|
||||
|
||||
@@ -5569,10 +5682,12 @@ public class WebManager {
|
||||
String reason = entry.getReason() != null ? entry.getReason() : "";
|
||||
String source = entry.getSource() != null ? entry.getSource() : "";
|
||||
long expireDate = entry.getExpiration() != null ? entry.getExpiration().getTime() : 0;
|
||||
long createdDate = entry.getCreated() != null ? entry.getCreated().getTime() : 0;
|
||||
sb.append("{\"type\":\"ip\",\"target\":\"").append(escapeJson(target))
|
||||
.append("\",\"reason\":\"").append(escapeJson(reason))
|
||||
.append("\",\"source\":\"").append(escapeJson(source))
|
||||
.append("\",\"expire\":").append(expireDate).append("}");
|
||||
.append("\",\"expire\":").append(expireDate)
|
||||
.append(",\"created\":").append(createdDate).append("}");
|
||||
hashBuilder.append("i:").append(target).append(":").append(reason).append(":").append(expireDate).append("|");
|
||||
}
|
||||
|
||||
@@ -5586,14 +5701,20 @@ public class WebManager {
|
||||
Set<org.bukkit.BanEntry<?>> ib0 = (Set<org.bukkit.BanEntry<?>>) (Set<?>) ipBanList.getEntries();
|
||||
for (org.bukkit.BanEntry<?> e0 : ib0) banRows.add(banRowOf(e0, "ip"));
|
||||
AlignResult arAlign = alignGate("bans", banRows);
|
||||
if (arAlign != null && !arAlign.needPush()) return; // 零发送
|
||||
// ★ 第十九批:created/ban_cutoff(PHP公示过滤历史封禁用)不进对账列,两端行一致时
|
||||
// alignGate 判「零发送」会把它们永远拦住 —— 进程内首轮 & 起点配置变化时强制整包推一轮;
|
||||
// 只绕过「零发送」,「本轮只收不发」保持原样(pull 后立即整包推会先把 PHP 多的行抹掉)。
|
||||
BanBroadcastManager bbCut = BanBroadcastManager.get();
|
||||
long cutoffNow = bbCut != null ? bbCut.getStartCutoff() : 0L;
|
||||
boolean forceCreatedPush = !bansCreatedPushed || cutoffNow != lastPushedCutoff;
|
||||
if (arAlign != null && !arAlign.needPush() && !forceCreatedPush) return; // 零发送
|
||||
if (arAlign != null && arAlign.pulled > 0) return; // 本轮只收不发
|
||||
|
||||
sb.append("]");
|
||||
String currentHash = hashBuilder.toString();
|
||||
// ★ 对账明说要推时本地 hash 不得拦截(同 admins,2026-10-06 灾备修复)
|
||||
boolean alignNeedsPushBans = arAlign != null && arAlign.needPush();
|
||||
if (!alignNeedsPushBans && currentHash.equals(lastBansHash)) {
|
||||
if (!alignNeedsPushBans && !forceCreatedPush && currentHash.equals(lastBansHash)) {
|
||||
plugin.getLogger().fine("[防护-sync] 封禁名单无变化,跳过");
|
||||
return;
|
||||
}
|
||||
@@ -5620,12 +5741,16 @@ public class WebManager {
|
||||
body.put("secret", secretKey);
|
||||
body.put("bans", sb.toString());
|
||||
body.put("ips", ipsb.toString());
|
||||
// ★ 第十九批:连同「播报起点」一起推给PHP(get_ban_board 用它过滤插件安装前的历史封禁)
|
||||
body.put("ban_cutoff", String.valueOf(cutoffNow));
|
||||
String resp = httpPost("api/sync.php?action=sync_bans", mapToJson(body));
|
||||
plugin.getLogger().info("[防护-sync] 封禁名单同步: " + (nameEntries.size() + ipEntries.size())
|
||||
+ "条(IP黑名单 " + ipOnlyEntries.size() + "个) → " + resp);
|
||||
// ★ 关键修复:hash 成功后才提交(防 404 时把 hash 写脏 → 永久漏推,2026-10-06)
|
||||
if (resp != null && resp.contains("\"success\":true")) {
|
||||
lastBansHash = currentHash;
|
||||
bansCreatedPushed = true;
|
||||
lastPushedCutoff = cutoffNow;
|
||||
} else {
|
||||
plugin.getLogger().warning("[防护-sync] 封禁名单同步未确认成功,下轮重试(hash 不提交)");
|
||||
}
|
||||
@@ -5702,6 +5827,8 @@ public class WebManager {
|
||||
|| existing.autoRenew != cfg.autoRenew
|
||||
|| existing.renewPrice != cfg.renewPrice
|
||||
|| existing.durationMinutes != cfg.durationMinutes
|
||||
|| existing.shopMaxListings != cfg.shopMaxListings
|
||||
|| existing.shopMinPrice != cfg.shopMinPrice
|
||||
|| !safeEq(existing.defaultPerms, cfg.defaultPerms);
|
||||
if (isDifferent) {
|
||||
ugm.saveGroupConfigToDB(cfg);
|
||||
@@ -5857,6 +5984,11 @@ public class WebManager {
|
||||
catch (Exception ignored) { cfg.maxEffectLevel = UserGroupManager.DEFAULT_MAX_EFFECT_LEVEL; }
|
||||
}
|
||||
if (cfg.maxEffectLevel <= 0) cfg.maxEffectLevel = UserGroupManager.DEFAULT_MAX_EFFECT_LEVEL;
|
||||
// ★ 二手商店(2026-10-11):上架数量/最低售价,-1=跟随全局
|
||||
String shMaxStr = extractJsonField(obj, "shop_max_listings");
|
||||
if (!shMaxStr.isEmpty()) try { cfg.shopMaxListings = Integer.parseInt(shMaxStr); } catch (Exception ignored) {}
|
||||
String shMinStr = extractJsonField(obj, "shop_min_price");
|
||||
if (!shMinStr.isEmpty()) try { cfg.shopMinPrice = Integer.parseInt(shMinStr); } catch (Exception ignored) {}
|
||||
String homeLimStr = extractJsonField(obj, "home_limit");
|
||||
if (!homeLimStr.isEmpty()) try { cfg.homeLimit = Integer.parseInt(homeLimStr); } catch (Exception ignored) {}
|
||||
String joinStr = extractJsonField(obj, "join_price");
|
||||
@@ -5874,9 +6006,10 @@ public class WebManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** 解码JSON中的 \\uXXXX Unicode转义(如 \\u00a7 → §) */
|
||||
/** 解码JSON中的 Unicode 转义(U+XXXX 形式,如 U+00A7 即 §) */
|
||||
private String decodeJsonUnicode(String s) {
|
||||
if (s == null || s.isEmpty()) return s;
|
||||
// 同上:这里探测的是 JSON 里的反斜杠+u 转义标记,不是 Java Unicode 转义。
|
||||
if (!s.contains("\\u")) return s;
|
||||
StringBuilder sb = new StringBuilder(s.length());
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
@@ -6081,7 +6214,9 @@ public class WebManager {
|
||||
try {
|
||||
Object regTimeObj = plugin.getDb().getField(newOwner, "register_time");
|
||||
if (regTimeObj instanceof Number) {
|
||||
registerTime = ((Number) regTimeObj).longValue();
|
||||
// ★ 第十一批:秒/毫秒混存归一化
|
||||
registerTime = DurationParser.toMs(
|
||||
((Number) regTimeObj).longValue());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 忽略异常
|
||||
@@ -6470,7 +6605,9 @@ public class WebManager {
|
||||
try {
|
||||
Object regTimeObj = dbMgr.getField(newOwner, "register_time");
|
||||
if (regTimeObj instanceof Number) {
|
||||
registerTime = ((Number) regTimeObj).longValue();
|
||||
// ★ 第十一批:秒/毫秒混存归一化
|
||||
registerTime = DurationParser.toMs(
|
||||
((Number) regTimeObj).longValue());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 忽略异常
|
||||
@@ -6862,12 +6999,14 @@ public class WebManager {
|
||||
private void startTxBatchRun(List<Map<String, Object>> txs) {
|
||||
final List<Map<String, Object>> queue = new ArrayList<>(txs);
|
||||
final int total = queue.size();
|
||||
final int batchCount = (total + TX_SYNC_BATCH_SIZE - 1) / TX_SYNC_BATCH_SIZE;
|
||||
// ★ 自适应批大小:本轮从下限起探,成功一批加一档、失败当场减半(见 TX_SYNC_BATCH_MIN 注释)
|
||||
final int atLeastBatches = (total + TX_SYNC_BATCH_MIN - 1) / TX_SYNC_BATCH_MIN;
|
||||
final int myRunId = txBatchRunSeq.incrementAndGet();
|
||||
txBatchRunId = myRunId;
|
||||
txBatchLastProgress = System.currentTimeMillis();
|
||||
plugin.getLogger().info("[Web交易同步] ▶ 启动本轮推进: 共 " + total + " 笔 / "
|
||||
+ batchCount + " 批(先点验后补推,多退少补)");
|
||||
plugin.getLogger().info("[Web交易同步] ▶ 启动本轮推进: 共 " + total + " 笔(先点验后补推,多退少补)"
|
||||
+ " | 批大小自适应 " + TX_SYNC_BATCH_MIN + "→" + TX_SYNC_BATCH_MAX
|
||||
+ "(≥" + atLeastBatches + " 批;每批成功 +" + TX_SYNC_BATCH_STEP + ",无回执减半)");
|
||||
|
||||
new BukkitRunnable() {
|
||||
int index = 0;
|
||||
@@ -6875,6 +7014,10 @@ public class WebManager {
|
||||
int pushedCount = 0;
|
||||
/** 当前批的连续失败次数(拿到回执推进后清零) */
|
||||
int batchRetries = 0;
|
||||
/** 自适应批大小:每轮从下限起探(见 TX_SYNC_BATCH_MIN 注释) */
|
||||
int curSize = TX_SYNC_BATCH_MIN;
|
||||
/** 批号独立计数:批大小自适应后不能由 index 推导;重试同一批时不递增 */
|
||||
int batchNo = 0;
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
@@ -6898,19 +7041,20 @@ public class WebManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 批号由 index 推导:重试同一批时批号保持不变,日志不虚增
|
||||
final int batchNo = index / TX_SYNC_BATCH_SIZE + 1;
|
||||
List<Map<String, Object>> batch =
|
||||
queue.subList(index, Math.min(index + TX_SYNC_BATCH_SIZE, queue.size()));
|
||||
// ★ 批号独立计数:批大小自适应后不能由 index 推导;重试同一批时不递增
|
||||
batchNo++;
|
||||
final int size = Math.max(1, Math.min(curSize, queue.size() - index));
|
||||
List<Map<String, Object>> batch = queue.subList(index, index + size);
|
||||
|
||||
plugin.getLogger().info("[Web交易同步] ● 第" + batchNo + "/" + batchCount
|
||||
plugin.getLogger().info("[Web交易同步] ● 第" + batchNo
|
||||
+ "批开始(" + batch.size() + " 笔,索引 " + index + "/" + total
|
||||
+ ",自适应批大小当前=" + size
|
||||
+ ",本批已重试 " + batchRetries + "/" + TX_SYNC_MAX_BATCH_RETRIES + ")");
|
||||
|
||||
boolean ok;
|
||||
int pushed;
|
||||
try {
|
||||
int[] r = syncTxBatch(batch, total, batchNo, batchCount);
|
||||
int[] r = syncTxBatch(batch, total, batchNo);
|
||||
ok = (r[0] == 1);
|
||||
pushed = r[1];
|
||||
} catch (Exception e) {
|
||||
@@ -6930,6 +7074,14 @@ public class WebManager {
|
||||
finishTxBatchRun();
|
||||
return;
|
||||
}
|
||||
// ★ 自适应退避:没拿到回执 → 批大小当场减半(不低于下限),
|
||||
// 用更小的批重试,顺势探出 PHP 的真实承受力
|
||||
int beforeDown = curSize;
|
||||
curSize = Math.max(TX_SYNC_BATCH_MIN, curSize / 2);
|
||||
if (curSize != beforeDown) {
|
||||
plugin.getLogger().warning("[Web交易同步] 自适应批大小退避: " + beforeDown
|
||||
+ " → " + curSize + "(本批没拿到回执,换更小的批重试)");
|
||||
}
|
||||
plugin.getLogger().warning("[Web交易同步] 第" + batchNo + "批没拿到回执,"
|
||||
+ (TX_SYNC_RECEIPT_RETRY_MS / 1000) + " 秒后重试同一批(第 "
|
||||
+ batchRetries + "/" + TX_SYNC_MAX_BATCH_RETRIES + " 次)");
|
||||
@@ -6940,13 +7092,20 @@ public class WebManager {
|
||||
batchRetries = 0;
|
||||
okCount += batch.size();
|
||||
pushedCount += pushed;
|
||||
index += TX_SYNC_BATCH_SIZE;
|
||||
index += size;
|
||||
// ★ 自适应加压:这批顺利拿到回执 → 批大小加一档(封顶 MAX),一点点试探 PHP 的胃口
|
||||
int beforeUp = curSize;
|
||||
curSize = Math.min(TX_SYNC_BATCH_MAX, curSize + TX_SYNC_BATCH_STEP);
|
||||
if (curSize != beforeUp) {
|
||||
plugin.getLogger().info("[Web交易同步] 自适应批大小加压: " + beforeUp + " → " + curSize);
|
||||
}
|
||||
|
||||
// ★ 回执节奏:HTTP success 即回执,收到才推下一批;
|
||||
// 真推了 PHP 的批次再冷却 15 秒让 PHP 消化,纯对账跳过的批次走短间隔
|
||||
long stepMs = pushed > 0 ? TX_SYNC_RECEIPT_STEP_MS : TX_SYNC_VERIFY_STEP_MS;
|
||||
plugin.getLogger().info("[Web交易同步] 第" + batchNo + "批回执已收到(补推 "
|
||||
+ pushed + " 笔)," + (stepMs / 1000) + " 秒后继续下一批");
|
||||
+ pushed + " 笔,下批大小=" + curSize + "),"
|
||||
+ (stepMs / 1000) + " 秒后继续下一批");
|
||||
scheduleTxStep(this, stepMs, myRunId);
|
||||
}
|
||||
}.runTaskLaterAsynchronously(plugin, 1L);
|
||||
@@ -7007,7 +7166,7 @@ public class WebManager {
|
||||
* 处理一批交易:先与 PHP 点验这批序列号,只补推缺失部分,成功后推进水位线
|
||||
* 返回 int[]{ 成功?(1:0), 实际补推笔数 }
|
||||
*/
|
||||
private int[] syncTxBatch(List<Map<String, Object>> batch, int total, int batchNo, int batchCount) {
|
||||
private int[] syncTxBatch(List<Map<String, Object>> batch, int total, int batchNo) {
|
||||
List<Long> ids = new ArrayList<>(batch.size());
|
||||
for (Map<String, Object> tx : batch) {
|
||||
Object iv = tx.get("id");
|
||||
@@ -7026,7 +7185,7 @@ public class WebManager {
|
||||
if (missing.isEmpty()) {
|
||||
// PHP 已经全都有 → 一笔都不推,只推进水位线(本批只花 1 次轻量对账请求)
|
||||
advanceTxWatermark(batch);
|
||||
plugin.getLogger().info("[Web交易同步] 第" + batchNo + "/" + batchCount
|
||||
plugin.getLogger().info("[Web交易同步] 第" + batchNo
|
||||
+ "批 PHP 已齐全(" + batch.size() + " 笔),跳过推送");
|
||||
return new int[]{1, 0};
|
||||
}
|
||||
@@ -7038,11 +7197,11 @@ public class WebManager {
|
||||
toPush.add(tx);
|
||||
}
|
||||
}
|
||||
plugin.getLogger().info("[Web交易同步] 第" + batchNo + "/" + batchCount
|
||||
plugin.getLogger().info("[Web交易同步] 第" + batchNo
|
||||
+ "批 仅缺 " + toPush.size() + "/" + batch.size() + " 笔,只补推缺失部分");
|
||||
}
|
||||
|
||||
if (!pushTxBatch(toPush, total, batchNo, batchCount)) {
|
||||
if (!pushTxBatch(toPush, total, batchNo)) {
|
||||
return new int[]{0, 0};
|
||||
}
|
||||
advanceTxWatermark(batch);
|
||||
@@ -7152,8 +7311,17 @@ public class WebManager {
|
||||
}
|
||||
|
||||
|
||||
/** 单批交易同步的最大笔数 */
|
||||
private static final int TX_SYNC_BATCH_SIZE = 300;
|
||||
/**
|
||||
* 交易补推批大小:自适应探测(2026-10-10)。
|
||||
* 用户要求:「300 笔是下限,每次一点点加,试探 PHP 一次能吃多少笔,且不会引起其它调度器崩溃」。
|
||||
* · 每轮从 TX_SYNC_BATCH_MIN 起推,每顺利拿到一批回执 +TX_SYNC_BATCH_STEP,直到 TX_SYNC_BATCH_MAX;
|
||||
* · 任一批没拿到回执(15 秒超时 / PHP 明确失败)→ 当场减半(不低于下限)后重试同一批,
|
||||
* 既探到 PHP 实际承受上限,又不会把大单持续怼向 PHP 拖垮其它调度器;
|
||||
* · 每轮重新从下限开始探,不跨轮记忆(避免把一次网络波动误学成固定上限)。
|
||||
*/
|
||||
private static final int TX_SYNC_BATCH_MIN = 300;
|
||||
private static final int TX_SYNC_BATCH_STEP = 300;
|
||||
private static final int TX_SYNC_BATCH_MAX = 3000;
|
||||
|
||||
/** 首次全量同步:每提交一项打一行进度,便于用户实时观察 */
|
||||
private void fullSyncStep(String what) {
|
||||
@@ -7163,7 +7331,7 @@ public class WebManager {
|
||||
/**
|
||||
* 推送一批交易到 PHP,成功则推进水位线,失败返回 false(调用方负责中止并保留水位线)
|
||||
*/
|
||||
private boolean pushTxBatch(List<Map<String, Object>> batch, int totalAll, int batchNo, int batchCount) {
|
||||
private boolean pushTxBatch(List<Map<String, Object>> batch, int totalAll, int batchNo) {
|
||||
try {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("transactions", batch);
|
||||
@@ -7171,19 +7339,19 @@ public class WebManager {
|
||||
// ★ 单次请求、15 秒超时:PHP 必须在窗口内给答复
|
||||
String response = httpPostWithTokenOnce("api/sync.php?action=sync_transactions", body);
|
||||
if (response == null) {
|
||||
plugin.getLogger().warning("[Web交易同步] 第" + batchNo + "/" + batchCount
|
||||
plugin.getLogger().warning("[Web交易同步] 第" + batchNo
|
||||
+ "批 ★ 15 秒内没有答复(超时/网络),不算成功也不算失败 → 等 15 秒重试同一批");
|
||||
return false;
|
||||
}
|
||||
Map<String, Object> result = parseJson(response);
|
||||
if (!Boolean.TRUE.equals(result.get("success"))) {
|
||||
plugin.getLogger().warning("[Web交易同步] 第" + batchNo + "/" + batchCount
|
||||
plugin.getLogger().warning("[Web交易同步] 第" + batchNo
|
||||
+ "批 ★ PHP 明确答复失败: " + result.get("message"));
|
||||
return false;
|
||||
}
|
||||
|
||||
// ★ 水位线由调用方 advanceTxWatermark 统一推进(对账跳过时也要推进)
|
||||
plugin.getLogger().info("[Web交易同步] 第" + batchNo + "/" + batchCount + "批 已推送 "
|
||||
plugin.getLogger().info("[Web交易同步] 第" + batchNo + "批 已推送 "
|
||||
+ batch.size() + " 笔(本轮共 " + totalAll + " 笔),回执=" + result.get("message"));
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
@@ -8500,6 +8668,12 @@ public class WebManager {
|
||||
sender.sendMessage("§c[Web] Web通信未启用");
|
||||
return;
|
||||
}
|
||||
// ★ 第30批:拉黑玩家禁止访问 PHP 后端
|
||||
if (plugin.getBlacklist() != null
|
||||
&& plugin.getBlacklist().isBlacklisted(sender.getName())) {
|
||||
sender.sendMessage("§c§l[罪人] §f你已被拉黑,禁止使用网页端功能");
|
||||
return;
|
||||
}
|
||||
|
||||
String playerName = sender.getName();
|
||||
long now = System.currentTimeMillis();
|
||||
@@ -8595,6 +8769,11 @@ public class WebManager {
|
||||
*/
|
||||
public void autoGenerateWebLoginToken(Player player) {
|
||||
if (!enabled) return;
|
||||
// ★ 第30批:拉黑玩家禁止访问 PHP 后端(不签发 token)
|
||||
if (plugin.getBlacklist() != null
|
||||
&& plugin.getBlacklist().isBlacklisted(player.getName())) {
|
||||
return;
|
||||
}
|
||||
|
||||
String playerName = player.getName();
|
||||
// 生成weblogin专用Token
|
||||
@@ -10707,6 +10886,7 @@ public class WebManager {
|
||||
*/
|
||||
private boolean updateLocalShopStock(String itemsJson) {
|
||||
try {
|
||||
loadShopStockSnapshot();
|
||||
File shopDir = new File(plugin.getDataFolder(), "shop");
|
||||
if (!shopDir.exists()) return false;
|
||||
|
||||
@@ -10787,10 +10967,22 @@ public class WebManager {
|
||||
|
||||
// ★ 管理员手动修改的库存:跳过保护逻辑,直接应用
|
||||
// 管理员设stock=0表示"售罄",需要同步到游戏
|
||||
if (!adminOverride.contains(itemId)) {
|
||||
if (adminOverride.contains(itemId)) {
|
||||
// 管理员显式改动:直接应用,并刷新同步基线
|
||||
shopStockSnapshot.put(itemId, newStock);
|
||||
} else {
|
||||
// stock<-1 为未定义值(-2以下),不生效,跳过同步
|
||||
// -1=无限库存, 0=售罄, >=1=有库存,都正常同步
|
||||
if (newStock < -1) continue;
|
||||
// ★ 数据分叉以 Java 为主:本地相对同步基线有未推送改动(snapStock != oldStockNum)
|
||||
// 且 PHP 值与本地不同(oldStockNum != newStock)-> 跳过覆盖,保留本地 md 值,
|
||||
// 随后由 pushShopStockDiffs() 推回 PHP 对齐。
|
||||
Integer snapStock = shopStockSnapshot.get(itemId);
|
||||
if (snapStock != null && snapStock != oldStockNum && oldStockNum != newStock) {
|
||||
continue;
|
||||
}
|
||||
// 应用 PHP 值并刷新同步基线
|
||||
shopStockSnapshot.put(itemId, newStock);
|
||||
}
|
||||
|
||||
if (oldStockNum != newStock) {
|
||||
@@ -10821,8 +11013,10 @@ public class WebManager {
|
||||
plugin.getLogger().info("[Web通信] 已同步Web端商品库存,更新了 " + updatedCount + " 个商品");
|
||||
lastPullShopStockLog = now;
|
||||
}
|
||||
saveShopStockSnapshot();
|
||||
return true;
|
||||
}
|
||||
saveShopStockSnapshot();
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[Web通信] 更新商品库存失败: " + e.getMessage());
|
||||
@@ -10830,6 +11024,185 @@ public class WebManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** 库存同步基线文件(插件 data 目录,盘上持久化,不依赖数据库) */
|
||||
private File shopStockSnapshotFile() {
|
||||
return new File(plugin.getDataFolder(), "shop_stock_snapshot.properties");
|
||||
}
|
||||
|
||||
/**
|
||||
* 懒加载库存同步基线(id->stock)。文件不存在 = 首装/基线丢失,
|
||||
* 按空基线处理 -> 首轮全量对齐推送(以 Java 本地为准)。
|
||||
*/
|
||||
private void loadShopStockSnapshot() {
|
||||
if (shopStockSnapshotLoaded) return;
|
||||
shopStockSnapshotLoaded = true;
|
||||
try {
|
||||
File f = shopStockSnapshotFile();
|
||||
if (!f.isFile()) return;
|
||||
java.util.Properties props = new java.util.Properties();
|
||||
java.io.InputStream in = new java.io.FileInputStream(f);
|
||||
try {
|
||||
props.load(in);
|
||||
} finally {
|
||||
in.close();
|
||||
}
|
||||
for (String key : props.stringPropertyNames()) {
|
||||
try {
|
||||
shopStockSnapshot.put(key, Integer.parseInt(props.getProperty(key).trim()));
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[Web通信] 读取库存同步基线失败(按空基线全量对齐): " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 库存同步基线落盘(推送/应用成功后才调用,防假成功写脏基线) */
|
||||
private void saveShopStockSnapshot() {
|
||||
try {
|
||||
java.util.Properties props = new java.util.Properties();
|
||||
for (Map.Entry<String, Integer> e : shopStockSnapshot.entrySet()) {
|
||||
props.setProperty(e.getKey(), String.valueOf(e.getValue()));
|
||||
}
|
||||
File f = shopStockSnapshotFile();
|
||||
File parent = f.getParentFile();
|
||||
if (parent != null && !parent.exists()) parent.mkdirs();
|
||||
java.io.OutputStream out = new java.io.FileOutputStream(f);
|
||||
try {
|
||||
props.store(out, "shop stock sync baseline id=stock");
|
||||
} finally {
|
||||
out.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[Web通信] 保存库存同步基线失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 库存双向同步(2026-10-10):把本地(md)相对同步基线的差异推回 PHP。
|
||||
* 用户原则:哪边有改动就对齐哪边;数据分叉以 Java 为主。
|
||||
* 基线只在推送成功后才更新(防假成功,与对账 hash 铁律同构)。
|
||||
*
|
||||
* @return true=无差异或已全部推送成功
|
||||
*/
|
||||
private boolean pushShopStockDiffs() {
|
||||
try {
|
||||
File shopDir = new File(plugin.getDataFolder(), "shop");
|
||||
if (!shopDir.exists()) return true;
|
||||
File[] mdFiles = shopDir.listFiles((d, n) -> n.endsWith(".md"));
|
||||
if (mdFiles == null || mdFiles.length == 0) return true;
|
||||
|
||||
loadShopStockSnapshot();
|
||||
|
||||
Map<String, Integer> current = new HashMap<>();
|
||||
for (File mdFile : mdFiles) {
|
||||
List<Map<String, Object>> catItems = parseMdShopFile(mdFile, mdFile.getName().replace(".md", ""));
|
||||
for (Map<String, Object> it : catItems) {
|
||||
Object idv = it.get("id");
|
||||
Object sv = it.get("stock");
|
||||
if (idv == null || sv == null) continue;
|
||||
current.put(String.valueOf(idv), ((Number) sv).intValue());
|
||||
}
|
||||
}
|
||||
if (current.isEmpty()) return true;
|
||||
|
||||
List<Map<String, Object>> diff = new ArrayList<>();
|
||||
for (Map.Entry<String, Integer> e : current.entrySet()) {
|
||||
Integer snap = shopStockSnapshot.get(e.getKey());
|
||||
if (snap == null || !snap.equals(e.getValue())) {
|
||||
Map<String, Object> one = new LinkedHashMap<>();
|
||||
one.put("id", e.getKey());
|
||||
one.put("stock", e.getValue());
|
||||
diff.add(one);
|
||||
}
|
||||
}
|
||||
if (diff.isEmpty()) return true;
|
||||
|
||||
String token = generateAndSyncToken("system", "sync");
|
||||
|
||||
// ★ 回执单校验(2026-10-11 用户要求):Java 不再无条件相信 PHP。
|
||||
// 推送带 push_id + 内容指纹 fp;PHP 必须在回执里原样回显两者(且条数一致)才算「确实收到」。
|
||||
// 无回执/回执不匹配 → 立即重推一次;两次都无有效回执 → 不落基线,下轮 poll 再推。
|
||||
List<String> fpEntries = new ArrayList<>();
|
||||
for (Map<String, Object> one : diff) {
|
||||
fpEntries.add(String.valueOf(one.get("id")) + "|" + ((Number) one.get("stock")).intValue());
|
||||
}
|
||||
Collections.sort(fpEntries);
|
||||
java.util.zip.CRC32 crc = new java.util.zip.CRC32();
|
||||
crc.update(String.join("\n", fpEntries).getBytes(StandardCharsets.UTF_8));
|
||||
String fp = String.valueOf(crc.getValue());
|
||||
String pushId = System.currentTimeMillis() + "-"
|
||||
+ java.util.concurrent.ThreadLocalRandom.current().nextInt(100000, 999999);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("items", diff);
|
||||
body.put("push_id", pushId);
|
||||
body.put("fp", fp);
|
||||
|
||||
String response = null;
|
||||
String badReason = "无响应";
|
||||
boolean gotReceipt = false;
|
||||
for (int attempt = 1; attempt <= 2 && !gotReceipt; attempt++) {
|
||||
if (attempt > 1) {
|
||||
plugin.getLogger().warning("[Web通信] 库存推送无有效回执(" + badReason
|
||||
+ ")→ 立即重推一次 push_id=" + pushId);
|
||||
}
|
||||
response = httpPostWithToken("api/sync.php?action=push_shop_stock", token, body);
|
||||
badReason = checkShopStockReceipt(response, pushId, fp, diff.size());
|
||||
gotReceipt = (badReason == null);
|
||||
}
|
||||
if (gotReceipt) {
|
||||
for (Map<String, Object> one : diff) {
|
||||
shopStockSnapshot.put(String.valueOf(one.get("id")), ((Number) one.get("stock")).intValue());
|
||||
}
|
||||
saveShopStockSnapshot();
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastShopStockPushLog > LOG_INTERVAL) {
|
||||
plugin.getLogger().info("[Web通信] 本地库存变更已推送 " + diff.size() + " 个商品(双向同步,分叉以游戏端为准,回执已核验 push_id=" + pushId + ")");
|
||||
lastShopStockPushLog = now;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
plugin.getLogger().warning("[Web通信] 库存推送失败(回执未通过[" + badReason + "],下轮重试): "
|
||||
+ (response == null ? "无响应" : response.substring(0, Math.min(160, response.length()))));
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[Web通信] 库存推送异常: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 库存推送回执单核验(2026-10-11):PHP 必须原样回显 push_id + 内容指纹 fp + 条数,
|
||||
* Java 才承认「PHP 确实收到」。仅匹配 success:true 不再算数(防中间层假 200 / 旧版 PHP)。
|
||||
* @return null=回执有效;非 null=失效原因(用于日志与重推判断)
|
||||
*/
|
||||
private String checkShopStockReceipt(String response, String pushId, String fp, int count) {
|
||||
if (response == null || response.isEmpty()) return "无响应";
|
||||
try {
|
||||
Gson gson = new Gson();
|
||||
JsonObject root = gson.fromJson(response, JsonObject.class);
|
||||
if (root == null || !root.has("success") || !root.get("success").getAsBoolean()) {
|
||||
String msg = (root != null && root.has("message") && !root.get("message").isJsonNull())
|
||||
? root.get("message").getAsString() : "success非true";
|
||||
return "PHP报错:" + msg;
|
||||
}
|
||||
if (!root.has("data") || !root.get("data").isJsonObject()) return "缺回执单(data)";
|
||||
JsonObject data = root.getAsJsonObject("data");
|
||||
if (!data.has("receipt") || !data.get("receipt").isJsonObject()) return "缺回执单(receipt)";
|
||||
JsonObject r = data.getAsJsonObject("receipt");
|
||||
String rid = (r.has("push_id") && !r.get("push_id").isJsonNull()) ? r.get("push_id").getAsString() : "";
|
||||
String rfp = (r.has("fp") && !r.get("fp").isJsonNull()) ? r.get("fp").getAsString() : "";
|
||||
int rcount = (r.has("count") && !r.get("count").isJsonNull()) ? r.get("count").getAsInt() : -1;
|
||||
if (!pushId.equals(rid)) return "push_id不匹配(回=" + rid + ")";
|
||||
if (!fp.equals(rfp)) return "指纹不匹配(期望" + fp + "回" + rfp + ")";
|
||||
if (rcount != count) return "条数不匹配(期望" + count + "回" + rcount + ")";
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
return "回执解析异常:" + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查同步通知文件
|
||||
*/
|
||||
|
||||
@@ -41,7 +41,7 @@ public class WelcomeManager {
|
||||
String section = "";
|
||||
String line;
|
||||
while ((line = r.readLine()) != null) {
|
||||
if (line.startsWith("\uFEFF"))
|
||||
if (line.startsWith(UnicodeChars.BOM_TEXT))
|
||||
line = line.substring(1);
|
||||
String t = line.trim();
|
||||
if (t.isEmpty()
|
||||
@@ -135,7 +135,7 @@ public class WelcomeManager {
|
||||
}
|
||||
|
||||
private String colorize(String msg) {
|
||||
return msg.replace("&", "\u00a7");
|
||||
return msg.replace("&", "§");
|
||||
}
|
||||
|
||||
private void writeDefault(File f) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
## ----配置区开始----
|
||||
|
||||
| 名字 | 功能说明 | 参数值 |
|
||||
| --- | --- | --- |
|
||||
| 轮播最低间隔 | 最短是多少秒一次(建议阈值60,太低很吵) | 60 |
|
||||
| 轮播最大间隔 | 最长多少秒一次(建议阈值300以内,太长起不到效果) | 180 |
|
||||
| 播报模式 | 是由远到近(即从第一个封禁的玩家开始报到最后一个),还是又近到远(从最新一个被封禁的玩家播报到第一个被封禁的玩家)。此项填0或最大最小间隔都为0表示关闭播报 | 由近到远 |
|
||||
| 播报起点 | 插件安装之前的封禁(历史封禁)不播报。填插件安装日期如2026-08-01,0或留空=自动(取插件数据目录创建时间) | 0 |
|
||||
|
||||
```json
|
||||
{
|
||||
"最小间隔":"",
|
||||
"最大间隔":"",
|
||||
"播报模式":true,
|
||||
"播报起点":"",
|
||||
"解释说明":{
|
||||
"播报模式":"true表示开启播报又近到远,false表示开启播报由远到近。0为关闭",
|
||||
"最小间隔":"最短是多少秒一次(建议阈值60,太低很吵)",
|
||||
"最大间隔":"最长多少秒一次(建议阈值300以内,太长起不到效果)",
|
||||
"播报起点":"插件安装之前的封禁不播报,填如2026-08-01;0或留空=自动"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ----配置区域结束----
|
||||
|
||||
## 功能说明
|
||||
- 此功能为服务器打造一个不可拆解的耻辱台,展示所有被封禁的玩家。json代码块的参数值和md表格是2选1,你可以删了表格,留json则表示用json作为配置值,也可以删了json用表格。小白用户推荐使用md,因为最简单,专业人员推荐使用json,因为最清晰,没有注释(注释是另起一行,不在配置块)。<br>
|
||||
- 同时兼容.json直接作为配置文件
|
||||
@@ -0,0 +1,30 @@
|
||||
### ----配置区域开始----
|
||||
|
||||
| 玩家名字 | 拉黑时长 | 理由 |
|
||||
| --- | --- | --- |
|
||||
|
||||
### ----配置区域结束
|
||||
|
||||
## 软折磨黑名单配置
|
||||
|
||||
功能说明:<br>
|
||||
此功能为软折磨,用于折磨违反规则的玩家。通过`/addblack user time(可选) msg(可选)`添加违规玩家或直接编辑本文件即可。被拉黑的玩家不能消费债券、不能登录PHP后端、不能创建领地,不能成为他人领地的授权玩家。不能破坏、放置方块,不能打开任何容器、不能拾取任何物品、不能与村民交易,没有背包(清空前备份,自动备份跳过该玩家)。能说话但,会被系统加上"---我是罪人user"的签名
|
||||
|
||||
## 示例配置:
|
||||
| 玩家名字 | 拉黑时长 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| steve | 终身 | 违反服务器规则 |
|
||||
| Dream | 2026-10-20 14:30 | 随意殴打他人 |
|
||||
|
||||
## 指令集
|
||||
|
||||
| 前缀 | 参数1 | 参数2 | 参数3 |
|
||||
| --- | --- | ---| --- |
|
||||
| /addblack | user | time | msg |
|
||||
| /removeblack | user | 空 | 空 |
|
||||
| /addblack | steve | 2026-10-20 10:00 | 违反服务器规则 |
|
||||
| /addblack | Dream | 0 | 空 |
|
||||
| /removeblack | Notch | 空 | 空 |
|
||||
|
||||
- 备注:0表示永久拉黑,填入日期+时间则表示遵循到期时间。填入日期则表示以当天23:59:59为界限。填相对时长(30分 / 2小时 / 3天 / 1周)= 从拉黑那一刻起算,到期自动解除并自动恢复背包与权限<br>
|
||||
- 生效规则: 定时,每分钟的第0秒、5秒、10秒...以此类推、55秒定时增量重载,删了5秒后立即恢复背包和各项权限,加了5秒后立即生效
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"开关": true,
|
||||
"是否合并两个箱子": 1,
|
||||
"解释说明": {
|
||||
"开关": "开启状态下,将自动接管手持精准采集斧头的、被授权「容器管理」权限的领地在拆箱动作;关闭则拆箱完全交还原版散落",
|
||||
"是否合并两个箱子": "即2秒内连续拆箱时,是否将两个箱子的物品合二为一个打包箱(不看塞满,只看时间窗口达标即合并)",
|
||||
"热重载": "玩家每次拆箱时增量热重载:文件 mtime+size 没变就不重新解析,变化才按新值生效",
|
||||
"支持参数": "同时支持填 true 为开 / false 为关;数字 0 为关 / 1 为开"
|
||||
}
|
||||
}
|
||||
@@ -33,3 +33,426 @@ party
|
||||
fun
|
||||
zone
|
||||
space
|
||||
name
|
||||
pro
|
||||
aero
|
||||
coop
|
||||
museum
|
||||
jobs
|
||||
travel
|
||||
asia
|
||||
cat
|
||||
edu
|
||||
gov
|
||||
int
|
||||
mil
|
||||
ac
|
||||
ad
|
||||
ae
|
||||
af
|
||||
ag
|
||||
ai
|
||||
al
|
||||
am
|
||||
ao
|
||||
aq
|
||||
ar
|
||||
as
|
||||
at
|
||||
au
|
||||
aw
|
||||
ax
|
||||
az
|
||||
ba
|
||||
bb
|
||||
bd
|
||||
be
|
||||
bf
|
||||
bg
|
||||
bh
|
||||
bi
|
||||
bj
|
||||
bm
|
||||
bn
|
||||
bo
|
||||
br
|
||||
bs
|
||||
bt
|
||||
bw
|
||||
by
|
||||
bz
|
||||
ca
|
||||
cd
|
||||
cg
|
||||
ch
|
||||
ci
|
||||
ck
|
||||
cl
|
||||
cm
|
||||
cr
|
||||
cu
|
||||
cv
|
||||
cy
|
||||
cz
|
||||
de
|
||||
dj
|
||||
dk
|
||||
dm
|
||||
do
|
||||
dz
|
||||
ec
|
||||
ee
|
||||
eg
|
||||
er
|
||||
es
|
||||
et
|
||||
eu
|
||||
fi
|
||||
fj
|
||||
fk
|
||||
fm
|
||||
fo
|
||||
fr
|
||||
gd
|
||||
ge
|
||||
gf
|
||||
gg
|
||||
gh
|
||||
gi
|
||||
gl
|
||||
gm
|
||||
gn
|
||||
gp
|
||||
gq
|
||||
gr
|
||||
gs
|
||||
gt
|
||||
gu
|
||||
gw
|
||||
gy
|
||||
hk
|
||||
hn
|
||||
hr
|
||||
ht
|
||||
hu
|
||||
id
|
||||
ie
|
||||
il
|
||||
im
|
||||
in
|
||||
iq
|
||||
ir
|
||||
is
|
||||
it
|
||||
je
|
||||
jm
|
||||
jo
|
||||
jp
|
||||
ke
|
||||
kg
|
||||
kh
|
||||
ki
|
||||
km
|
||||
kn
|
||||
kp
|
||||
kr
|
||||
kw
|
||||
ky
|
||||
kz
|
||||
la
|
||||
lb
|
||||
lc
|
||||
li
|
||||
lk
|
||||
lr
|
||||
ls
|
||||
lt
|
||||
lu
|
||||
lv
|
||||
ly
|
||||
ma
|
||||
mc
|
||||
md
|
||||
me
|
||||
mg
|
||||
mh
|
||||
mk
|
||||
mm
|
||||
mn
|
||||
mo
|
||||
mp
|
||||
mq
|
||||
mr
|
||||
ms
|
||||
mt
|
||||
mu
|
||||
mv
|
||||
mw
|
||||
mx
|
||||
my
|
||||
mz
|
||||
na
|
||||
nc
|
||||
ne
|
||||
nf
|
||||
ng
|
||||
ni
|
||||
nl
|
||||
no
|
||||
np
|
||||
nr
|
||||
nu
|
||||
nz
|
||||
om
|
||||
pa
|
||||
pe
|
||||
pf
|
||||
pg
|
||||
ph
|
||||
pk
|
||||
pl
|
||||
pm
|
||||
pn
|
||||
pr
|
||||
ps
|
||||
pt
|
||||
pw
|
||||
py
|
||||
qa
|
||||
re
|
||||
ro
|
||||
rs
|
||||
rw
|
||||
sa
|
||||
sb
|
||||
sc
|
||||
sd
|
||||
se
|
||||
sg
|
||||
sh
|
||||
si
|
||||
sk
|
||||
sl
|
||||
sm
|
||||
sn
|
||||
so
|
||||
sr
|
||||
ss
|
||||
st
|
||||
sv
|
||||
sx
|
||||
sy
|
||||
sz
|
||||
tc
|
||||
td
|
||||
tf
|
||||
tg
|
||||
th
|
||||
tj
|
||||
tl
|
||||
tm
|
||||
tn
|
||||
to
|
||||
tt
|
||||
tw
|
||||
tz
|
||||
ug
|
||||
uk
|
||||
um
|
||||
us
|
||||
uy
|
||||
uz
|
||||
va
|
||||
vc
|
||||
ve
|
||||
vg
|
||||
vi
|
||||
vn
|
||||
vu
|
||||
wf
|
||||
ye
|
||||
yt
|
||||
za
|
||||
zm
|
||||
zw
|
||||
ac.uk
|
||||
co.uk
|
||||
org.uk
|
||||
gov.uk
|
||||
edu.au
|
||||
ac.nz
|
||||
co.nz
|
||||
org.nz
|
||||
gov.au
|
||||
co.jp
|
||||
ne.jp
|
||||
or.jp
|
||||
ed.jp
|
||||
ac.jp
|
||||
go.jp
|
||||
co.kr
|
||||
or.kr
|
||||
re.kr
|
||||
go.kr
|
||||
ac.kr
|
||||
ne.kr
|
||||
com.cn
|
||||
net.cn
|
||||
org.cn
|
||||
gov.cn
|
||||
edu.cn
|
||||
ac.cn
|
||||
mil.cn
|
||||
com.hk
|
||||
net.hk
|
||||
org.hk
|
||||
idv.hk
|
||||
com.tw
|
||||
net.tw
|
||||
org.tw
|
||||
gov.tw
|
||||
edu.tw
|
||||
com.sg
|
||||
net.sg
|
||||
org.sg
|
||||
gov.sg
|
||||
edu.sg
|
||||
per.sg
|
||||
com.my
|
||||
net.my
|
||||
org.my
|
||||
gov.my
|
||||
edu.my
|
||||
per.my
|
||||
com.vn
|
||||
net.vn
|
||||
org.vn
|
||||
gov.vn
|
||||
edu.vn
|
||||
ac.vn
|
||||
com.ph
|
||||
net.ph
|
||||
org.ph
|
||||
gov.ph
|
||||
edu.ph
|
||||
com.pk
|
||||
net.pk
|
||||
org.pk
|
||||
gov.pk
|
||||
edu.pk
|
||||
com.bd
|
||||
net.bd
|
||||
org.bd
|
||||
gov.bd
|
||||
edu.bd
|
||||
com.lk
|
||||
net.lk
|
||||
gov.lk
|
||||
ac.lk
|
||||
com.np
|
||||
net.np
|
||||
org.np
|
||||
gov.np
|
||||
com.mm
|
||||
com.kh
|
||||
com.kg
|
||||
com.kz
|
||||
com.ge
|
||||
com.am
|
||||
com.az
|
||||
com.by
|
||||
com.km
|
||||
com.sc
|
||||
co.id
|
||||
web.id
|
||||
co.th
|
||||
or.th
|
||||
ac.th
|
||||
go.th
|
||||
net.th
|
||||
com.mu
|
||||
org.mu
|
||||
co.za
|
||||
org.za
|
||||
web.za
|
||||
com.eg
|
||||
net.eg
|
||||
com.ma
|
||||
net.ma
|
||||
org.ma
|
||||
co.ke
|
||||
or.ke
|
||||
ac.ke
|
||||
co.tz
|
||||
or.tz
|
||||
ac.tz
|
||||
co.ug
|
||||
or.ug
|
||||
com.sn
|
||||
net.sn
|
||||
org.sn
|
||||
co.rw
|
||||
ac.rw
|
||||
com.cm
|
||||
co.bi
|
||||
co.dz
|
||||
com.dj
|
||||
co.so
|
||||
co.tg
|
||||
co.np
|
||||
ac.cy
|
||||
com.cy
|
||||
co.il
|
||||
org.il
|
||||
net.il
|
||||
ac.il
|
||||
gov.il
|
||||
co.ma
|
||||
com.kw
|
||||
net.kw
|
||||
org.kw
|
||||
com.om
|
||||
net.om
|
||||
com.ps
|
||||
com.sy
|
||||
com.jo
|
||||
net.jo
|
||||
com.lb
|
||||
net.lb
|
||||
com.tn
|
||||
com.tj
|
||||
com.tf
|
||||
app
|
||||
dev
|
||||
page
|
||||
shop
|
||||
work
|
||||
website
|
||||
wiki
|
||||
blog
|
||||
live
|
||||
best
|
||||
cool
|
||||
red
|
||||
icu
|
||||
one
|
||||
ltd
|
||||
# ===== 中文域名(真实可注册,按最后一段匹配)=====
|
||||
# ICANN 官方中文 TLD
|
||||
中国
|
||||
网络
|
||||
公司
|
||||
我爱你
|
||||
# .cn 注册局中文通用域名
|
||||
集团
|
||||
商城
|
||||
网店
|
||||
在线
|
||||
游戏
|
||||
网址
|
||||
网站
|
||||
商店
|
||||
企业
|
||||
娱乐
|
||||
移动
|
||||
中文网
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
## ----配置区域开始----
|
||||
|
||||
| 颜色代码 |
|
||||
| --- |
|
||||
| red |
|
||||
| aqua |
|
||||
| |
|
||||
|
||||
## ----配置区域结束----
|
||||
|
||||
## json解码规则说明
|
||||
|
||||
### 一、怎么触发?
|
||||
使用`json`指令触发,或在原版`title、tellraw`产生报错时,插件介入。
|
||||
### 二、颜色代码怎么配置?
|
||||
支持填入`16进制`颜色代码(比如#000000)、或者颜色`英文名`(比如red)、也可以是rgb。比如(R0 G0 B0)
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Sdf1_login
|
||||
version: 2.92
|
||||
version: 3.01
|
||||
main: Sdf1_login.Main
|
||||
api-version: '1.21'
|
||||
commands:
|
||||
@@ -26,16 +26,16 @@ commands:
|
||||
usage: /玩家信息
|
||||
改密码:
|
||||
description: 修改密码(中文)
|
||||
usage: /改密码 <旧密码> <新密码> <确认新密码>
|
||||
usage: /改密码 <旧密码> <新密码>
|
||||
设置邮箱:
|
||||
description: 设置邮箱(中文)
|
||||
usage: /设置邮箱 <邮箱地址>
|
||||
撤销:
|
||||
description: 撤销操作(中文)
|
||||
usage: /撤销
|
||||
删除:
|
||||
description: 删除玩家(中文)
|
||||
usage: /删除 <玩家> <密码>
|
||||
# 撤销:
|
||||
# description: 撤销操作(中文)
|
||||
# usage: /撤销
|
||||
# 删除:
|
||||
# description: 删除玩家(中文)
|
||||
# usage: /删除 <玩家> <密码>
|
||||
找回密码:
|
||||
description: 找回密码(中文)
|
||||
usage: /找回密码
|
||||
@@ -45,21 +45,21 @@ commands:
|
||||
#oa:
|
||||
# description: 申请删除账号
|
||||
# usage: /oa [玩家名]
|
||||
删除账号:
|
||||
description: 申请删除账号(中文)
|
||||
usage: /删除账号 [玩家名]
|
||||
通过:
|
||||
description: 批准OA请求(中文)
|
||||
usage: /通过 <编号>
|
||||
拒绝:
|
||||
description: 拒绝OA请求(中文)
|
||||
usage: /拒绝 <编号> [理由]
|
||||
accept:
|
||||
description: 批准OA请求
|
||||
usage: /accept <编号>
|
||||
deny:
|
||||
description: 拒绝OA请求
|
||||
usage: /deny <编号> [理由]
|
||||
# 删除账号:
|
||||
# description: 申请删除账号(中文)
|
||||
# usage: /删除账号 [玩家名]
|
||||
# 通过:
|
||||
# description: 批准OA请求(中文)
|
||||
# usage: /通过 <编号>
|
||||
# 拒绝:
|
||||
# description: 拒绝OA请求(中文)
|
||||
# usage: /拒绝 <编号> [理由]
|
||||
# accept:
|
||||
# description: 批准OA请求
|
||||
# usage: /accept <编号>
|
||||
# deny:
|
||||
# description: 拒绝OA请求
|
||||
# usage: /deny <编号> [理由]
|
||||
email:
|
||||
description: 绑定邮箱
|
||||
usage: /email <邮箱>
|
||||
@@ -78,9 +78,21 @@ commands:
|
||||
recycle:
|
||||
description: 打开垃圾回收站
|
||||
usage: /recycle
|
||||
公告板:
|
||||
description: 刷新/查看服务器公告计分板
|
||||
usage: /公告板
|
||||
垃圾清理:
|
||||
description: 手动清理掉落物
|
||||
usage: /垃圾清理
|
||||
垃圾抢救:
|
||||
description: 紧急取消本轮垃圾站清理(物品误投时抢救)
|
||||
usage: /垃圾抢救
|
||||
addblack:
|
||||
description: 拉黑玩家(禁止建领地/破坏/兑换码/网页端,债券冻结,背包备份清空)
|
||||
usage: /addblack <玩家名> [时长] [理由]
|
||||
removeblack:
|
||||
description: 解除拉黑
|
||||
usage: /removeblack <玩家名>
|
||||
# menu:
|
||||
# description: 雪球菜单开关控制
|
||||
# usage: /menu <开/关>
|
||||
@@ -96,6 +108,9 @@ commands:
|
||||
shop :
|
||||
description: 游戏商人
|
||||
usage: /shop
|
||||
shopbuy :
|
||||
description: 二手交易商店
|
||||
usage: /shopbuy
|
||||
商店:
|
||||
description: 打开商店
|
||||
usage: /商店
|
||||
@@ -181,9 +196,15 @@ commands:
|
||||
who:
|
||||
description: 查看在线玩家列表
|
||||
usage: /who
|
||||
banget:
|
||||
description: 点名查询封禁(全量,含插件安装前的历史封禁)
|
||||
usage: /banget [user|ip] <玩家名或IP>
|
||||
清空队列:
|
||||
description: 清空DB队列中未回应的web同步任务(管理员)
|
||||
usage: /清空队列
|
||||
cleartake:
|
||||
description: 清空DB队列中未回应的web同步任务(管理员)
|
||||
usage: /cleartake
|
||||
json:
|
||||
description: JSON消息构造器(tellraw/大标题/小标题/actionbar+颜色,需OP以上权限)
|
||||
usage: /json
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
|
||||
## ----配置区域开始----
|
||||
| 项目 | 解释 | 值 |
|
||||
| :---: | :---: | :---: |
|
||||
| 可上架的商品数量 | 每位普通玩家能上架多少个物品。数值填写为纯数字。用户组另算 | 3 |
|
||||
| 最低售价 | 普通玩家上架物品时,最低能买多少钱,数值填写为纯数字。用户组另算 | 0 |
|
||||
| 手续费 | 二手交易物品的抽成比例,上限10。支持三种写法:小数 `0.05`=5%、`0.005`=5‰;带单位 `5%`、`5‰`。 | 0.05 |
|
||||
| 手续费支付方 | 谁出手续费,买方出即订单加费(奸商模式)或卖方出(到手后的价格已包含完税) | 卖方出 |
|
||||
| 默认管理员面板密码 | 面板后台的验证凭据 | qweasd |
|
||||
| 谁是管理员 | 插件全局tag管理员就填tag,OP则填写OP,指定玩家则填写player+名字 | tag |
|
||||
|
||||
```json
|
||||
{
|
||||
"默认下架理由":"一行一个",
|
||||
"理由列表":[
|
||||
"商品包含违禁品",
|
||||
"商品售价非法",
|
||||
"商品描述与实物不符",
|
||||
"疑似诈骗或诱导交易",
|
||||
"商品利用漏洞复制",
|
||||
"恶意炒作价格",
|
||||
"占位刷屏恶意上架",
|
||||
"违反二手交易规则其他条款"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## ----配置区域结束---
|
||||
|
||||
## 二手交易商店说明书
|
||||
- 什么是二手商店: 二手商店的定位就类似于闲鱼这类二手交易平台。货品挂在平台内,平台平台进行担保<br>
|
||||
- 什么不能卖: sdf1_game宝箱的物品不能卖,本插件的SN物品不能卖<br>
|
||||
- 怎么配置: 只需要改对应的值,比如可上架的商品数量,只要改数字,比如100,1000就行<br>
|
||||
- 手续费怎么算:
|
||||
|
||||
| 交易金额 | 谁出 | 手续费多少钱 | 卖家到手多少钱 | 买家支付多少钱 | 计算公式 |
|
||||
| :---: | :---: | :---: | :---: | :---: | :---: |
|
||||
| 100债券 | 卖家出 | 5% | 95 | 100 | 100(原价)×5%(0.05)=5(手续费金额),100(原价)-5(手续费)=95(卖家到手) |
|
||||
| 100债券 | 买家出 | 5% | 100 | 105 | 100(原价)×5%(0.05)=5(手续费金额),100(原价)+5(手续费)=105(买家实付金额) |
|
||||
| 100债券 | 卖家出 | 5‰(=0.005) | 99 | 100 | 100(原价)×5‰=0.5,手续费四舍五入=1,100-1=99(卖家到手);千分位适合低抽成场景 |
|
||||
| 100债券 | 买家出 | 5‰ | 100 | 101 | 100(原价)×5‰(手续费)=0.5(4舍5入),100(原价)+1(手续费取整)=101 |
|
||||
|
||||
- 改完多久生效: 自动增量重载,平时安安静静,有人下单了,发布新商品了自动拉起增量重载,有变更就对齐。每个整点(每分钟的第0秒、15秒、30秒、45秒)自动拉起增量热重载,有变更就对齐,没有则跳过<br>
|
||||
- 默认理由最大支持8个,第9号位为返回。<br>
|
||||
- 谁是管理员这一栏填单格式:`player+example`→指定example为管理员,`op`→指定全服OP为管理员,`tag`→指定插件的tag玩家是管理员
|
||||
@@ -0,0 +1,65 @@
|
||||
## ----公告区域开始----
|
||||
```json
|
||||
{
|
||||
"开关状态":true,
|
||||
"翻页时间":15,
|
||||
"1": {
|
||||
"msg": "草原探险服务器欢迎您",
|
||||
"color": "green"
|
||||
},
|
||||
"2": {
|
||||
"msg": "服务器官方群:981954292",
|
||||
"color": "aqua"
|
||||
},
|
||||
"3":{
|
||||
"msg":"最低支持版本:1.21.11",
|
||||
"color":"gold"
|
||||
},
|
||||
"4":{
|
||||
"msg":"最大支持版本:26.x",
|
||||
"color":"#FF55FF"
|
||||
},
|
||||
"5":{
|
||||
"msg":"服务器ip: mc2.ypshidifu.cn",
|
||||
"color":"blue"
|
||||
},
|
||||
"6":{
|
||||
"msg":"端口:30679,Java免输入~"
|
||||
}
|
||||
}
|
||||
```
|
||||
## ----公告区域结束----
|
||||
|
||||
## 公告板使用手册
|
||||
- 插件主导计分板式公告栏,用于记录和公示服务器的基本信息。<br>
|
||||
- 支持填入任意纯文本。<br>
|
||||
- 空白键值自动跳过
|
||||
|
||||
## 公告区参数选项
|
||||
| 键 | 值 | 必填 | 解释 |
|
||||
| --- | --- | --- |--------------------------------------------|
|
||||
| 数字编号 | 花括号 | 必填 | 一条公告的开始 |
|
||||
| msg | 纯文本消息内容 | 必填 | 消息正文 |
|
||||
| color | 颜色英文名或16进制颜色代码,或rgb | 非必填 | 指定颜色,未填写回退MC原版逻辑(继承最近一个color参数,如果都没有就回退白色) |
|
||||
|
||||
## 示例模板
|
||||
|
||||
```json
|
||||
{
|
||||
"1": {
|
||||
"msg": "这是第一条公告",
|
||||
"color": ""
|
||||
},
|
||||
"2": {
|
||||
"msg": "这是第二条公告",
|
||||
"color": "r=0 g=0 b=0"
|
||||
},
|
||||
"3": {
|
||||
"msg": "这是第三条公告",
|
||||
"color": "#000000"
|
||||
},
|
||||
"4": {
|
||||
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# 邀好友赢债券活动配置
|
||||
|
||||
## ----配置区开始----
|
||||
| 项目 | 解释 | 值 |
|
||||
| :---: | :---: | :---: |
|
||||
| 邀请人单次奖励 | 邀请人每次成功邀请一位好友后,立即结算获得的债券数量。数值填写为纯数字,单位默认为"个"。适用于"马上邀、马上结账"的场景。 | 10 |
|
||||
| 被邀请人单次奖励 | 被邀请人在注册并首次上线后,立即结算获得的债券数量。数值填写为纯数字,用于吸引新玩家加入。 | 5 |
|
||||
| 单日邀请奖励上限 | 单个邀请人在**一天内**通过邀请好友累计可获得的债券总数上限。达到上限后当日不再发放奖励,次日自动重置。适合控制薅羊毛行为。 | 100 |
|
||||
| 单日邀请翻倍额度 | 拉了多少个人以后,给邀请人的奖励。参数值为:"每邀请X人,奖金翻Y倍" | 每邀请2人,奖金翻1倍 |
|
||||
|
||||
## ----配置区域结束----
|
||||
|
||||
## 邀好友赢债券说明书
|
||||
- 什么是邀好友赢债券:玩家邀请新玩家加入服务器,双方完成任务后分别获得债券奖励,激励玩家拉新。<br>
|
||||
- 三种配置的区别:
|
||||
- **邀请人单次奖励**:每次成功邀请后,邀请人立即获得的债券数,不累积
|
||||
- **被邀请人单次奖励**:被邀请人注册并首次上线后,自己立即获得的债券数
|
||||
- **单日邀请奖励上限**:邀请人一天内累计获得的总奖励上限(仅针对邀请人),达到后当日停止发放,次日重置
|
||||
- 计算公式示例:
|
||||
- 邀请人单次奖励 = 10,单人当天邀请 3 人 → 累计获得 10 × 3 = **30 债券**(未超单日上限)
|
||||
- 邀请人单次奖励 = 10,单人当天邀请 11 人 → 理论应得 10 × 11 = 110,但被单日上限卡住,实际获得 **100 债券**,当日不再发放
|
||||
- 被邀请人单次奖励 = 5,新玩家注册首次上线 → 立即获得 **5 债券**
|
||||
|
||||
Reference in New Issue
Block a user