diff options
119 files changed, 8592 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..541d00f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.gradle +.settings +kls_database.db +build +bukkit/build +core/build @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 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 General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + 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. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + 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 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. Use with the GNU Affero General Public License. + + 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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 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 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 General Public License for more details. + + You should have received a copy of the GNU 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 the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + 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 GPL, see +<https://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<https://www.gnu.org/licenses/why-not-lgpl.html>. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..aacb54e --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + kotlin("jvm") version "2.2.0" +} + +allprojects { + group = "cat.freya.khs" + version = "2.0.0" + + repositories { + mavenCentral() + } + +} + +subprojects { + apply(plugin = "org.jetbrains.kotlin.jvm") + apply(plugin = "java-library") + + dependencies { + implementation(kotlin("stdlib")) + } + + kotlin { + jvmToolchain { + languageVersion.set(JavaLanguageVersion.of(8)) + } + } + + tasks.jar { + archiveBaseName.set("khs") + } +} + diff --git a/bukkit/build.gradle.kts b/bukkit/build.gradle.kts new file mode 100644 index 0000000..b018562 --- /dev/null +++ b/bukkit/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("com.gradleup.shadow") version "8.3.1" +} + +repositories { + maven("https://hub.spigotmc.org/nexus/content/repositories/public/") + maven("https://repo.codemc.io/repository/maven-releases/") +} + +dependencies { + compileOnly("org.spigotmc:spigot-api:1.21.11-R0.1-SNAPSHOT") + compileOnly("com.github.retrooper:packetevents-spigot:2.11.2") + implementation("com.github.cryptomorin:XSeries:13.6.0") + implementation(project(":core")) +} + +kotlin { + sourceSets.main { + kotlin.srcDirs("src") + resources.srcDirs("res") + } +} + +tasks.shadowJar { + archiveBaseName.set("KenshinsHideAndSeek") + archiveClassifier.set("") +} diff --git a/bukkit/res/plugin.yml b/bukkit/res/plugin.yml new file mode 100644 index 0000000..cc93ebe --- /dev/null +++ b/bukkit/res/plugin.yml @@ -0,0 +1,76 @@ +name: KenshinsHideAndSeek +main: cat.freya.khs.bukkit.KhsPlugin +version: 2.0.0 +author: KenshinEto +load: STARTUP +api-version: 1.13 +depend: [packetevents] +softdepend: [PlaceholderAPI] +commands: + hideandseek: + description: Hide and Seek command + usage: /hideandseek [command] + aliases: hs +permissions: + hs.help: + default: true + hs.join: + default: true + hs.leave: + default: true + hs.top: + default: true + hs.wins: + default: true + hs.reload: + default: op + hs.send: + default: op + hs.setexit: + default: op + hs.start: + default: op + hs.stop: + default: op + hs.map.add: + default: op + hs.map.remove: + default: op + hs.map.list: + default: op + hs.map.status: + default: op + hs.map.save: + default: op + hs.map.debug: + default: op + hs.map.goto: + default: op + hs.map.set.lobby: + default: op + hs.map.set.spawn: + default: op + hs.map.set.seekerlobby: + default: op + hs.map.set.border: + default: op + hs.map.set.bounds: + default: op + hs.map.blockhunt.add: + default: op + hs.map.blockhunt.remove: + default: op + hs.map.blockhunt.list: + default: op + hs.world.create: + default: op + hs.world.delete: + default: op + hs.world.list: + default: op + hs.world.tp: + default: op + hs.confirm: + default: op + hs.leavebounds: + default: false diff --git a/bukkit/src/Board.kt b/bukkit/src/Board.kt new file mode 100644 index 0000000..041d39f --- /dev/null +++ b/bukkit/src/Board.kt @@ -0,0 +1,126 @@ +@file:Suppress("DEPRECATION") + +package cat.freya.khs.bukkit + +import cat.freya.khs.game.Board as KhsBoard +import java.util.UUID +import org.bukkit.scoreboard.DisplaySlot +import org.bukkit.scoreboard.NameTagVisibility +import org.bukkit.scoreboard.Objective +import org.bukkit.scoreboard.Scoreboard as BukkitBoard +import org.bukkit.scoreboard.Team as BukkitTeam + +class BukkitKhsTeam(val shim: BukkitKhsShim, val inner: BukkitTeam) : KhsBoard.Team { + override var prefix: String + get() = inner.prefix + set(prefix: String) { + inner.prefix = formatText(prefix) + } + + enum class NameTagsVisible { + FOR_OWN_TEAM, + FOR_OTHER_TEAMS, + NEVER, + } + + // options + override var canCollide: Boolean + get() { + if (!shim.supports(9)) return false + return inner.getOption(BukkitTeam.Option.COLLISION_RULE) == + BukkitTeam.OptionStatus.NEVER + } + set(b: Boolean) { + if (shim.supports(9)) { + val v = if (b) BukkitTeam.OptionStatus.ALWAYS else BukkitTeam.OptionStatus.NEVER + inner.setOption(BukkitTeam.Option.COLLISION_RULE, v) + } + } + + override var nameTagsVisible: Boolean + get() { + if (shim.supports(9)) { + return inner.getOption(BukkitTeam.Option.NAME_TAG_VISIBILITY) != + BukkitTeam.OptionStatus.NEVER + } else { + return inner.nameTagVisibility != NameTagVisibility.NEVER + } + } + set(b: Boolean) { + if (shim.supports(9)) { + val v = + if (b) BukkitTeam.OptionStatus.FOR_OWN_TEAM else BukkitTeam.OptionStatus.NEVER + inner.setOption(BukkitTeam.Option.NAME_TAG_VISIBILITY, v) + } else { + val v = if (b) NameTagVisibility.HIDE_FOR_OTHER_TEAMS else NameTagVisibility.NEVER + inner.nameTagVisibility = v + } + } + + // players + override var players: Set<UUID> + get() = inner.entries.map { shim.getPlayer(it)?.uuid }.filterNotNull().toSet() + set(new: Set<UUID>) { + for (entry in inner.entries) { + val player = shim.plugin.server.getPlayer(entry) ?: null + if (!new.contains(player?.uniqueId)) inner.removeEntry(entry) + } + for (uuid in new) { + val player = shim.plugin.server.getPlayer(uuid) ?: continue + inner.addEntry(player.name) + } + } +} + +class BukkitKhsBoard(val shim: BukkitKhsShim, val inner: BukkitBoard) : KhsBoard { + private var objective: Objective? = null + private var blanks: Int = 0 + + private fun resetObjective() { + if (shim.supports(13)) { + objective = inner.registerNewObjective("Scoreboard", "dummy", "") + } else { + objective = inner.registerNewObjective("Scoreboard", "dummy") + } + blanks = 0 + } + + private fun addLine(i: Int, line: String) { + val score = objective?.getScore(formatText(line)) + score?.setScore(i + 1) + } + + private fun addBlank(i: Int) { + blanks++ + addLine(i, " ".repeat(blanks)) + } + + override fun setText(title: String, text: List<String>) { + resetObjective() + + // set title + objective?.displayName = formatText(title) + + // set content + for ((i, line) in text.withIndex()) { + if (line.trim().isEmpty()) { + addBlank(i) + continue + } + + addLine(i, line) + } + } + + override fun getTeam(name: String): KhsBoard.Team { + runCatching { inner.registerNewTeam(name) } + val team = inner.getTeam(name) ?: error("failed to make team ?!?") + return BukkitKhsTeam(shim, team) + } + + override fun display(uuid: UUID) { + val player = shim.getPlayer(uuid) ?: return + objective?.setDisplaySlot(DisplaySlot.SIDEBAR) + (player as BukkitKhsPlayer).inner.setScoreboard(inner) + } +} diff --git a/bukkit/src/Inventory.kt b/bukkit/src/Inventory.kt new file mode 100644 index 0000000..2a1d7ea --- /dev/null +++ b/bukkit/src/Inventory.kt @@ -0,0 +1,59 @@ +package cat.freya.khs.bukkit + +import cat.freya.khs.player.Inventory as KhsInventory +import cat.freya.khs.player.PlayerInventory as KhsPlayerInventory +import cat.freya.khs.world.Item +import org.bukkit.inventory.Inventory as BukkitInventory +import org.bukkit.inventory.PlayerInventory as BukkitPlayerInventory + +open class BukkitKhsInventory( + open val shim: BukkitKhsShim, + open val inner: BukkitInventory, + open override val title: String?, +) : KhsInventory { + override fun get(index: UInt): Item? = inner.getItem(index.toInt())?.let { toKhsItem(it) } + + override fun set(index: UInt, item: Item) = + inner.setItem(index.toInt(), (item as BukkitKhsItem).inner) + + override fun remove(item: Item) = inner.remove((item as BukkitKhsItem).inner) + + override var contents: List<Item?> + get() = inner.contents.map { toKhsItem(it) } + set(contents: List<Item?>) = + inner.setContents(contents.map { (it as BukkitKhsItem).inner }.toTypedArray()) + + override fun clear() { + inner.clear() + } +} + +class BukkitKhsPlayerInventory( + override val shim: BukkitKhsShim, + override val inner: BukkitPlayerInventory, + override val title: String?, +) : BukkitKhsInventory(shim, inner, title), KhsPlayerInventory { + override var helmet: Item? + get() = toKhsItem(inner.helmet) + set(item: Item?) { + inner.helmet = (item as? BukkitKhsItem)?.inner + } + + override var chestplate: Item? + get() = toKhsItem(inner.chestplate) + set(item: Item?) { + inner.chestplate = (item as? BukkitKhsItem)?.inner + } + + override var leggings: Item? + get() = toKhsItem(inner.leggings) + set(item: Item?) { + inner.leggings = (item as? BukkitKhsItem)?.inner + } + + override var boots: Item? + get() = toKhsItem(inner.boots) + set(item: Item?) { + inner.boots = (item as? BukkitKhsItem)?.inner + } +} diff --git a/bukkit/src/Item.kt b/bukkit/src/Item.kt new file mode 100644 index 0000000..40646e2 --- /dev/null +++ b/bukkit/src/Item.kt @@ -0,0 +1,97 @@ +package cat.freya.khs.bukkit + +import cat.freya.khs.config.EffectConfig +import cat.freya.khs.config.ItemConfig +import cat.freya.khs.world.Effect as KhsEffect +import cat.freya.khs.world.Item as KhsItem +import com.cryptomorin.xseries.XItemStack +import com.cryptomorin.xseries.XMaterial +import kotlin.collections.emptyMap +import org.bukkit.configuration.file.YamlConfiguration +import org.bukkit.inventory.ItemStack +import org.bukkit.inventory.meta.SkullMeta +import org.bukkit.potion.PotionEffect + +class BukkitKhsItem(val inner: ItemStack, override val config: ItemConfig) : KhsItem { + override val name: String? = inner.itemMeta?.displayName + override val material: String = inner.type?.name ?: "NONE" + + override fun clone(): KhsItem = BukkitKhsItem(inner, config) + + override fun similar(config: ItemConfig): Boolean { + var item = parseBukkitItem(config) ?: return false + return inner.isSimilar(item.inner) + } + + override fun similar(material: String): Boolean { + val xMaterial = XMaterial.matchXMaterial(material).orElse(null) ?: return false + return xMaterial.isSimilar(inner) + } +} + +class BukkitKhsEffect(val inner: PotionEffect, override val config: EffectConfig) : KhsEffect { + @Suppress("DEPRECATION") override val name: String? = inner.type.name + + override fun clone(): KhsEffect = BukkitKhsEffect(inner, config) +} + +fun parseBukkitItem(itemConfig: ItemConfig): BukkitKhsItem? { + var config = YamlConfiguration().createSection("temp") + var materialParts = itemConfig.material.uppercase().split(":") + var material = materialParts.first() + + // set name and material + config.set("name", itemConfig.name?.let { formatText(it) }) + config.set("material", material) + if (!itemConfig.lore.isEmpty()) config.set("lore", itemConfig.lore.map { formatText(it) }) + config.set("unbreakable", itemConfig.unbreakable ?: false) + + // parse enchantments + var enchantments = YamlConfiguration().createSection("enchantments") + for ((enchantment, value) in itemConfig.enchantments) enchantments.set( + enchantment, + value.toInt(), + ) + config.set("enchants", enchantments) + + // set custom model data (1.14+) + if (itemConfig.modelData != null) config.set("model-data", itemConfig.modelData?.toInt()) + + // TODO: potions are broken on 1.8 + + // set potion data + if (material.endsWith("POTION")) { + var potionType = materialParts.getOrNull(1) ?: "AKWARD" + config.set("base-type", potionType) + } + + val item = runCatching { + val item = (XItemStack.Deserializer()).withConfig(config).read() ?: return null + + // set player head owner (if skull) + if (itemConfig.owner != null && itemConfig.material == "PLAYER_HEAD") { + val meta = item.itemMeta as SkullMeta + meta.setOwner(itemConfig.owner) + item.itemMeta = meta + } + + BukkitKhsItem(item, itemConfig) + } + + return item.getOrDefault(null) +} + +fun toKhsItem(inner: ItemStack?): BukkitKhsItem? { + if (inner == null) return null + + val config = ItemConfig() + config.name = inner.itemMeta?.displayName + config.material = inner.type?.name ?: "NONE" + config.lore = inner.itemMeta?.lore ?: listOf() + @Suppress("DEPRECATION") + config.enchantments = + inner.itemMeta?.enchants?.mapKeys { it.key.name }?.mapValues { it.value.toUInt() } + ?: emptyMap() + config.unbreakable = inner.itemMeta?.isUnbreakable + return BukkitKhsItem(inner, config) +} diff --git a/bukkit/src/Player.kt b/bukkit/src/Player.kt new file mode 100644 index 0000000..a551ee5 --- /dev/null +++ b/bukkit/src/Player.kt @@ -0,0 +1,232 @@ +package cat.freya.khs.bukkit + +import cat.freya.khs.bukkit.packet.EntityMetadataPacket +import cat.freya.khs.player.Inventory as KhsInventory +import cat.freya.khs.player.Player as KhsPlayer +import cat.freya.khs.player.Player.GameMode as KhsGameMode +import cat.freya.khs.player.PlayerInventory as KhsPlayerInventory +import cat.freya.khs.world.Effect +import cat.freya.khs.world.Location +import cat.freya.khs.world.Position +import cat.freya.khs.world.World as KhsWorld +import com.cryptomorin.xseries.XMaterial +import com.cryptomorin.xseries.XSound +import com.cryptomorin.xseries.messages.ActionBar +import com.cryptomorin.xseries.messages.Titles +import com.google.common.io.ByteStreams +import org.bukkit.Color +import org.bukkit.FireworkEffect +import org.bukkit.GameMode as BukkitGameMode +import org.bukkit.attribute.Attribute +import org.bukkit.entity.EntityType +import org.bukkit.entity.Firework +import org.bukkit.entity.Player as BukkitPlayer +import org.bukkit.potion.PotionEffect +import org.bukkit.potion.PotionEffectType +import org.bukkit.util.Vector + +class BukkitKhsPlayer(val shim: BukkitKhsShim, val inner: BukkitPlayer) : KhsPlayer { + override val uuid = inner.uniqueId + override val name = inner.name + + override val location: Location + get() { + val loc = inner.location + return Location(loc.x, loc.y, loc.z, inner.world.name.intern()) + } + + override val world: KhsWorld? + get() = shim.getWorld(location.worldName) + + override var health: Double + get() = inner.health + set(v: Double) { + inner.health = v + } + + override var hunger: UInt + get() = inner.foodLevel.toUInt() + set(v: UInt) { + inner.foodLevel = v.toInt() + } + + override fun heal() { + if (shim.supports(9)) { + val attribName = if (shim.supports(21, 6)) "MAX_HEALTH" else "GENERIC_MAX_HEALTH" + var attrib = inner.getAttribute(Attribute.valueOf(attribName)) + health = attrib?.value ?: 20.0 + } else { + @Suppress("DEPRECATION") + health = inner.maxHealth + } + } + + override var allowFlight + get() = inner.allowFlight + set(v: Boolean) { + inner.allowFlight = v + } + + override var flying + get() = inner.isFlying + set(flying: Boolean) { + if (this.flying != flying) inner.setFallDistance(0f) + runCatching { inner.setFlying(flying) } + } + + override fun teleport(position: Position) { + val loc = Location(position.x, position.y, position.z, inner.world.name) + teleport(loc) + } + + override fun teleport(location: Location) { + var world = shim.plugin.server.getWorld(location.worldName) + if (world == null) { + // attempt to load the world + val loader = shim.getWorldLoader(location.worldName) + loader.load() + world = shim.plugin.server.getWorld(location.worldName) + } + val x = location.x + val y = location.y + val z = location.z + val pos = org.bukkit.Location(world, x, y, z) + + // sanity check + if (world == null) { + shim.logger.warning("Could not teleport $name to $x,$y,$z in ${location.worldName}") + return + } + + inner.teleport(pos) + } + + override fun sendToServer(server: String) { + val out = ByteStreams.newDataOutput() + out.writeUTF("Connect") + out.writeUTF(shim.plugin.khs.config.leaveServer) + inner.sendPluginMessage(shim.plugin, "BungeeCord", out.toByteArray()) + } + + override val inventory: KhsPlayerInventory + get() = BukkitKhsPlayerInventory(shim, inner.inventory, null) + + override fun showInventory(inv: KhsInventory) { + inner.openInventory((inv as BukkitKhsInventory).inner) + } + + override fun closeInventory() { + inner.closeInventory() + } + + override fun clearEffects() { + for (effect in inner.activePotionEffects) inner.removePotionEffect(effect.type) + } + + override fun giveEffect(effect: Effect) { + inner.addPotionEffect((effect as BukkitKhsEffect).inner) + } + + override fun setSpeed(amplifier: UInt) { + inner.addPotionEffect(PotionEffect(PotionEffectType.SPEED, 1000000, 5, false, false)) + } + + override fun setGlow(target: KhsPlayer, glow: Boolean) { + val entity = (target as BukkitKhsPlayer).inner + val packet = EntityMetadataPacket(entity, glow) + packet.send(inner) + } + + override fun setHidden(target: KhsPlayer, hidden: Boolean) { + var other = (target as BukkitKhsPlayer).inner + if (shim.supports(12, 2)) { + if (hidden) inner.hidePlayer(shim.plugin, other) + else inner.showPlayer(shim.plugin, other) + } else { + @Suppress("DEPRECATION") + if (hidden) inner.hidePlayer(other) else inner.showPlayer(other) + } + } + + override fun message(message: String) { + inner.sendMessage(formatText(message)) + } + + override fun actionBar(message: String) { + ActionBar.clearActionBar(inner) + ActionBar.sendActionBar(inner, formatText(message)) + } + + override fun title(title: String, subTitle: String) { + Titles.clearTitle(inner) + Titles.sendTitle(inner, 10, 40, 10, formatText(title), formatText(subTitle)) + } + + override fun playSound(sound: String, volume: Double, pitch: Double) { + XSound.REGISTRY.getByName(sound).ifPresent { + it.play(inner, volume.toFloat(), pitch.toFloat()) + } + } + + override fun isDisguised(): Boolean = shim.plugin.disguiser.getDisguise(inner) != null + + override fun disguise(material: String) { + runCatching { + val xmat = XMaterial.matchXMaterial(material).orElse(null) ?: return + val mat = xmat.get() ?: return + shim.plugin.disguiser.disguise(inner, mat) + } + } + + override fun revealDisguise() { + shim.plugin.disguiser.reveal(inner) + } + + override fun hasPermission(permission: String): Boolean { + return inner.hasPermission(permission) + } + + override fun setGameMode(gameMode: KhsGameMode) { + inner.setGameMode( + when (gameMode) { + KhsGameMode.CREATIVE -> BukkitGameMode.CREATIVE + KhsGameMode.SURVIVAL -> BukkitGameMode.SURVIVAL + KhsGameMode.ADVENTURE -> BukkitGameMode.ADVENTURE + KhsGameMode.SPECTATOR -> BukkitGameMode.SPECTATOR + } + ) + } + + override fun hideBoards() { + val manager = shim.plugin.server.scoreboardManager ?: return + inner.setScoreboard(manager.mainScoreboard) + } + + override fun taunt() { + val world = this.world?.let { it as BukkitKhsWorld } ?: return + val loc = org.bukkit.Location(world.inner, location.x, location.y, location.z) + + // spawn firework + val fwMatName = if (shim.supports(13)) "FIREWORK_ROCKET" else "FIREWORK" + val fwMat = EntityType.valueOf(fwMatName) + val fw = world.inner.spawnEntity(loc, fwMat) as Firework + fw.setVelocity(Vector(0, 1, 0)) + + // make it pretty + val meta = fw.fireworkMeta + meta.setPower(4) + meta.addEffect( + FireworkEffect.builder() + .withColor(Color.BLUE) + .withColor(Color.RED) + .withColor(Color.YELLOW) + .with(FireworkEffect.Type.STAR) + .with(FireworkEffect.Type.BALL) + .with(FireworkEffect.Type.BALL_LARGE) + .flicker(true) + .withTrail() + .build() + ) + fw.fireworkMeta = meta + } +} diff --git a/bukkit/src/Plugin.kt b/bukkit/src/Plugin.kt new file mode 100644 index 0000000..325d4cd --- /dev/null +++ b/bukkit/src/Plugin.kt @@ -0,0 +1,87 @@ +package cat.freya.khs.bukkit + +import cat.freya.khs.Khs +import cat.freya.khs.bukkit.disguise.Disguiser +import cat.freya.khs.bukkit.disguise.EntityHider +import cat.freya.khs.bukkit.event.* +import org.bukkit.command.Command +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player as BukkitPlayer +import org.bukkit.plugin.java.JavaPlugin +import org.bukkit.scheduler.BukkitRunnable + +class KhsPlugin : JavaPlugin() { + val shim: BukkitKhsShim = BukkitKhsShim(this) + val khs: Khs = Khs(shim) + + // for blockhunt + val disguiser: Disguiser = Disguiser(this) + val entityHider: EntityHider = EntityHider() + + override fun onEnable() { + khs.init() + + if (!this.isEnabled()) return + + // make sure onTick is run + object : BukkitRunnable() { + override fun run() { + khs.onTick() + disguiser.update() + } + } + .runTaskTimer(this, 0, 1) + + // register bungee cord + server.messenger.registerOutgoingPluginChannel(this, "BungeeCord") + + registerListeners() + } + + override fun onDisable() { + khs.cleanup() + disguiser.cleanup() + } + + private fun registerListeners() { + BreakListener(this) + ChatListener(this) + CommandListener(this) + DamageListener(this) + InteractListener(this) + InventoryListener(this) + JoinLeaveListener(this) + MovementListener(this) + PlayerListener(this) + PacketListener(this) + RespawnListener(this) + } + + fun scheduleTask(fn: () -> Unit) { + if (!isEnabled) return + server.scheduler.runTask(this, fn) + } + + override fun onCommand( + sender: CommandSender, + cmd: Command, + label: String, + args: Array<String>, + ): Boolean { + val player = sender as? BukkitPlayer ?: return false + val khsPlayer = BukkitKhsPlayer(shim, player) + khs.commandGroup.handleCommand(khsPlayer, args.toList()) + return true + } + + override fun onTabComplete( + sender: CommandSender, + cmd: Command, + label: String, + args: Array<String>, + ): List<String> { + val player = sender as? BukkitPlayer ?: return listOf() + val khsPlayer = BukkitKhsPlayer(shim, player) + return khs.commandGroup.handleTabComplete(khsPlayer, args.toList()) + } +} diff --git a/bukkit/src/Shim.kt b/bukkit/src/Shim.kt new file mode 100644 index 0000000..fa568f9 --- /dev/null +++ b/bukkit/src/Shim.kt @@ -0,0 +1,180 @@ +package cat.freya.khs.bukkit + +import cat.freya.khs.KhsShim +import cat.freya.khs.Logger +import cat.freya.khs.config.EffectConfig +import cat.freya.khs.config.ItemConfig +import cat.freya.khs.game.Board as KhsBoard +import cat.freya.khs.player.Inventory as KhsInventory +import cat.freya.khs.player.Player as KhsPlayer +import cat.freya.khs.world.Effect as KhsEffect +import cat.freya.khs.world.Item as KhsItem +import cat.freya.khs.world.World as KhsWorld +import com.cryptomorin.xseries.XMaterial +import java.io.File +import java.io.InputStream +import java.util.UUID +import kotlin.jvm.optionals.getOrNull +import org.bukkit.ChatColor +import org.bukkit.World as BukkitWorld +import org.bukkit.WorldCreator +import org.bukkit.WorldType +import org.bukkit.potion.PotionEffect +import org.bukkit.potion.PotionEffectType + +class BukkitLogger(val plugin: KhsPlugin) : Logger { + override fun info(message: String) = plugin.logger.info(message) + + override fun warning(message: String) = plugin.logger.warning(message) + + override fun error(message: String) = plugin.logger.severe(message) +} + +class BukkitKhsShim(val plugin: KhsPlugin) : KhsShim { + override val pluginVersion: String + override val mcVersion: List<UInt> + override val platform: String = "bukkit" + + init { + // parse mc version + mcVersion = + Regex("""MC:\s*([\d.]+)""") + .find(plugin.server.version) + ?.groupValues + ?.get(1) + ?.split('.') + ?.asSequence() + ?.mapNotNull { it.toUIntOrNull() } + ?.let { seq -> if (seq.firstOrNull() == 1u) seq.drop(1) else seq } + ?.toList() ?: emptyList() + + pluginVersion = plugin.description.version + } + + override val logger = BukkitLogger(plugin) + + override val players: List<KhsPlayer> + get() = plugin.server.onlinePlayers.map { BukkitKhsPlayer(this, it) } + + override val worlds: List<String> + get() = + plugin.server.worldContainer + .listFiles() + .filter { + if (!it.isDirectory) return@filter false + + val session = File(it, "session.lock") + val level = File(it, "level.dat") + + session.exists() && level.exists() + } + .map { it.name } + + override val sqliteDatabasePath: String + get() { + val legacy = File(plugin.dataFolder.path, "database.db") + if (legacy.exists()) return legacy.path + + return File(plugin.dataFolder.path, "khs.db").path + } + + override fun readConfigFile(fileName: String): InputStream? { + val dir = plugin.dataFolder + if (!dir.exists()) { + dir.mkdirs() || error("Failed to make plugin config directory") + } + val file = File(dir, fileName) + return if (file.exists()) file.inputStream() else null + } + + override fun writeConfigFile(fileName: String, content: String) { + val dir = plugin.dataFolder + if (!dir.exists()) { + dir.mkdirs() || error("Failed to make plugin config directory") + } + val file = File(dir, fileName) + file.writeText(content) + } + + override fun parseMaterial(materialName: String): String? { + return XMaterial.matchXMaterial(materialName).getOrNull()?.get()?.toString() + } + + override fun parseItem(itemConfig: ItemConfig): KhsItem? { + return parseBukkitItem(itemConfig) + } + + override fun parseEffect(effectConfig: EffectConfig): KhsEffect? { + @Suppress("DEPRECATION") + val type = PotionEffectType.getByName(effectConfig.type.uppercase()) ?: return null + val inner = + PotionEffect( + type, + effectConfig.duration.toInt(), + effectConfig.amplifier.toInt(), + effectConfig.ambient, + effectConfig.particles, + ) + + return BukkitKhsEffect(inner, effectConfig) + } + + override fun getPlayer(uuid: UUID): KhsPlayer? { + return plugin.server.getPlayer(uuid)?.let { BukkitKhsPlayer(this, it) } + } + + override fun getPlayer(name: String): KhsPlayer? { + return plugin.server.getPlayer(name)?.let { BukkitKhsPlayer(this, it) } + } + + override fun getWorld(worldName: String): KhsWorld? { + return plugin.server.getWorld(worldName)?.let { BukkitKhsWorld(this, it) } + } + + override fun getWorldLoader(worldName: String): KhsWorld.Loader { + return BukkitKhsWorldLoader(plugin, worldName) + } + + override fun createWorld(worldName: String, type: KhsWorld.Type): KhsWorld? { + val worldType = if (type == KhsWorld.Type.FLAT) WorldType.FLAT else WorldType.NORMAL + val env = + when (type) { + KhsWorld.Type.NETHER -> BukkitWorld.Environment.NETHER + KhsWorld.Type.END -> BukkitWorld.Environment.THE_END + else -> BukkitWorld.Environment.NORMAL + } + val creator = WorldCreator(worldName) + creator.type(worldType) + creator.environment(env) + plugin.server.createWorld(creator) + var world = plugin.server.getWorld(worldName) ?: return null + world.save() + return BukkitKhsWorld(plugin.shim, world) + } + + override fun createInventory(title: String, size: UInt): KhsInventory? { + val inv = plugin.server.createInventory(null, size.toInt(), title) + return BukkitKhsInventory(this, inv, title) + } + + override fun getBoard(name: String): KhsBoard? { + val board = plugin.server.scoreboardManager?.getNewScoreboard() ?: return null + return BukkitKhsBoard(this, board) + } + + override fun broadcast(message: String) { + plugin.server.broadcastMessage(formatText(message)) + } + + override fun disable() { + plugin.server.pluginManager.disablePlugin(plugin) + } + + override fun scheduleEvent(ticks: ULong, event: () -> Unit) { + plugin.server.scheduler.scheduleSyncDelayedTask(plugin, event, ticks.toLong()) + } +} + +fun formatText(message: String): String { + return ChatColor.translateAlternateColorCodes('&', message) +} diff --git a/bukkit/src/World.kt b/bukkit/src/World.kt new file mode 100644 index 0000000..afda77a --- /dev/null +++ b/bukkit/src/World.kt @@ -0,0 +1,157 @@ +package cat.freya.khs.bukkit + +import cat.freya.khs.world.Position +import cat.freya.khs.world.World as KhsWorld +import cat.freya.khs.world.World.Border as KhsWorldBorder +import cat.freya.khs.world.World.Loader as KhsWorldLoader +import java.io.File +import java.util.Random +import org.bukkit.GameRule +import org.bukkit.Location as BukkitLocation +import org.bukkit.World as BukkitWorld +import org.bukkit.WorldBorder as BukkitWorldBorder +import org.bukkit.WorldCreator +import org.bukkit.WorldType +import org.bukkit.block.Biome +import org.bukkit.generator.BlockPopulator +import org.bukkit.generator.ChunkGenerator + +class VoidGenerator : ChunkGenerator() { + // 1.14 And On + override fun getDefaultPopulators(world: BukkitWorld): List<BlockPopulator> { + return listOf() + } + + override fun canSpawn(world: BukkitWorld, x: Int, z: Int): Boolean { + return true + } + + override fun getFixedSpawnLocation(world: BukkitWorld, random: Random): BukkitLocation { + return BukkitLocation(world, 0.0, 0.0, 0.0) + } + + // 1.13 And Prev + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") + override fun generateChunkData( + world: BukkitWorld, + random: Random, + chunkX: Int, + chunkZ: Int, + biome: BiomeGrid, + ): ChunkData { + val chunkData = createChunkData(world) + + for (x in 0 until 16) for (z in 0 until 16) biome.setBiome(x, z, Biome.PLAINS) + + return chunkData + } + + // 1.8 + fun generate(world: BukkitWorld, random: Random, x: Int, z: Int): ByteArray { + return ByteArray(world.maxHeight / 16) + } + + @Suppress("DEPRECATION") + fun generateBlockSections( + world: BukkitWorld, + random: Random, + x: Int, + z: Int, + biomes: ChunkGenerator.BiomeGrid, + ): Array<ByteArray> { + return Array(world.maxHeight / 16) { ByteArray(0) } + } +} + +class BukkitKhsWorldBorder(val world: BukkitKhsWorld, val inner: BukkitWorldBorder) : + KhsWorldBorder { + override val x: Double = inner.center.x + override val z: Double = inner.center.z + override val size: Double = inner.size + + override fun move(newX: Double, newZ: Double, newSize: ULong, delay: ULong) { + inner.setCenter(newX, newZ) + move(newSize, delay) + } + + override fun move(newSize: ULong, delay: ULong) { + inner.setSize(newSize.toDouble(), delay.toLong()) + } +} + +class BukkitKhsWorldLoader(val plugin: KhsPlugin, val worldName: String) : KhsWorldLoader { + override val name: String = worldName + override val world: KhsWorld? = plugin.shim.getWorld(worldName) + + override val dir: File + get() = File(plugin.server.worldContainer, name) + + override val saveDir: File + get() = File(plugin.server.worldContainer, "hs_$name") + + override val tempSaveDir: File + get() = File(plugin.server.worldContainer, "temp_hs_$name") + + override fun load() { + plugin.server.createWorld(WorldCreator(name).generator(VoidGenerator())) + val world = plugin.server.getWorld(name) + if (world == null) { + plugin.shim.logger.error("could not load world: $name") + } + world?.setAutoSave(false) + if (plugin.shim.supports(21, 6)) world?.setGameRule(GameRule.LOCATOR_BAR, false) + } + + override fun unload() { + val world = plugin.server.getWorld(name) + if (world == null) return // world already unloaded + + world.players.forEach { player -> + val khsPlayer = BukkitKhsPlayer(plugin.shim, player) + plugin.khs.config.exit?.teleport(khsPlayer) + } + + if (plugin.server.unloadWorld(name, false) == false) + plugin.shim.logger.error("could not unload world: $name") + } + + override fun rollback() { + unload() + load() + } +} + +class BukkitKhsWorld(val shim: BukkitKhsShim, val inner: BukkitWorld) : KhsWorld { + override val name = inner.name + override val type: KhsWorld.Type + get() { + val env = inner.environment + if (env == BukkitWorld.Environment.NETHER) return KhsWorld.Type.NETHER + if (env == BukkitWorld.Environment.THE_END) return KhsWorld.Type.END + + @Suppress("DEPRECATION") + return when (inner.worldType) { + WorldType.NORMAL -> KhsWorld.Type.NORMAL + WorldType.FLAT -> KhsWorld.Type.FLAT + else -> KhsWorld.Type.UNKNOWN + } + } + + override val minY: Int + get() = if (shim.supports(18)) -64 else 0 + + override val maxY: Int + get() = if (shim.supports(18)) 320 else 256 + + override val spawn: Position + get() { + val loc = inner.spawnLocation + return Position(loc.x, loc.y, loc.z) + } + + override val border: KhsWorldBorder + get() = BukkitKhsWorldBorder(this, inner.worldBorder) + + override val loader: KhsWorldLoader + get() = shim.getWorldLoader(name) +} diff --git a/bukkit/src/disguise/Disguise.kt b/bukkit/src/disguise/Disguise.kt new file mode 100644 index 0000000..8c86188 --- /dev/null +++ b/bukkit/src/disguise/Disguise.kt @@ -0,0 +1,244 @@ +package cat.freya.khs.bukkit.disguise + +import cat.freya.khs.bukkit.KhsPlugin +import cat.freya.khs.bukkit.packet.BlockChangePacket +import cat.freya.khs.bukkit.packet.EntityTeleportPacket +import com.cryptomorin.xseries.XSound +import com.cryptomorin.xseries.messages.ActionBar +import kotlin.math.round +import org.bukkit.Location +import org.bukkit.Material +import org.bukkit.entity.AbstractHorse +import org.bukkit.entity.Entity as BukkitEntity +import org.bukkit.entity.EntityType +import org.bukkit.entity.FallingBlock +import org.bukkit.entity.LivingEntity +import org.bukkit.entity.Player as BukkitPlayer +import org.bukkit.potion.PotionEffect +import org.bukkit.potion.PotionEffectType +import org.bukkit.scoreboard.Team + +private fun makeInvisible(entity: LivingEntity) { + if (entity.hasPotionEffect(PotionEffectType.INVISIBILITY)) return + + entity.addPotionEffect(PotionEffect(PotionEffectType.INVISIBILITY, 1_000_000, 0, false, false)) +} + +private fun getCollidesTeam(plugin: KhsPlugin): Team? { + val KHS_DISGUISE_TEAM_NAME = "KHS_disguised" + val scoreboard = plugin.server.scoreboardManager?.mainScoreboard ?: return null + val team = + scoreboard.getTeam(KHS_DISGUISE_TEAM_NAME) + ?: scoreboard.registerNewTeam(KHS_DISGUISE_TEAM_NAME) + team.setOption(Team.Option.COLLISION_RULE, Team.OptionStatus.NEVER) + team.setCanSeeFriendlyInvisibles(false) + return team +} + +private fun setCollides(plugin: KhsPlugin, player: BukkitPlayer, collides: Boolean) { + if (plugin.shim.supports(9)) { + val team = getCollidesTeam(plugin) + if (collides) team?.removeEntry(player.name) else team?.addEntry(player.name) + } else { + val method = player.spigot().javaClass.getMethod("setCollidesWithEntities") + method.invoke(player, collides) + } +} + +class Disguise(val plugin: KhsPlugin, val player: BukkitPlayer, val material: Material) { + var block: FallingBlock? = null + var blockLocation: Location? = null + var hitBox: AbstractHorse? = null + var timer: UInt = 0u + + @Volatile var shouldBeSolid: Boolean = false + @Volatile var isSolid: Boolean = false + @Volatile var hasSolidifyingTask: Boolean = false + + init { + // make sure the player does not collide + setCollides(plugin, player, false) + } + + val entityId: Int? + get() = block?.entityId + + val hitBoxId: Int? + get() = hitBox?.entityId + + fun update() { + if (block?.isDead() != false) { + block?.remove() + respawnFallingBlock() + } + + if (shouldBeSolid) { + if (!isSolid) { + isSolid = true + blockLocation = player.location.block.location + respawnHitbox() + } + sendBlockUpdate(blockLocation, material) + } else if (isSolid) { + isSolid = false + removeHitbox() + removeFallingBlock() + respawnFallingBlock() + sendBlockUpdate(blockLocation, Material.AIR) + } + + updateVisiblity() + teleportEntity(hitBox, true) + teleportEntity(block, isSolid) + + // do this here is it can be + // cleared by the core game logic + makeInvisible(player) + } + + fun remove() { + block?.remove() + removeHitbox() + setCollides(plugin, player, true) + player.removePotionEffect(PotionEffectType.INVISIBILITY) + if (isSolid) sendBlockUpdate(blockLocation, Material.AIR) + } + + fun removeFallingBlock() { + block?.remove() + block = null + } + + fun respawnFallingBlock() { + val world = player.location.world ?: return + val loc = player.location.add(0.0, 1000.0, 0.0) + + val block: FallingBlock? = + runCatching { world.spawnFallingBlock(loc, material.createBlockData()) } + .getOrElse { null } + if (block == null) return + + if (plugin.shim.supports(10)) block.setGravity(false) + + block.setDropItem(false) + block.setInvulnerable(true) + + this.block = block + } + + fun respawnHitbox() { + val world = player.location.world ?: return + + // we only want the hitbox to be at our postion + // when we are solidified + val loc = player.location.add(0.0, 1000.0, 0.0) + + val hitBox: AbstractHorse? = + if (plugin.shim.supports(11)) { + world.spawnEntity(loc, EntityType.SKELETON_HORSE) as AbstractHorse + } else { + world.spawnEntity(loc, EntityType.HORSE) as AbstractHorse + } + + if (hitBox == null) return + + if (plugin.shim.supports(10)) hitBox.setGravity(false) + + val id = hitBox.uniqueId.toString() + if (plugin.shim.supports(9)) getCollidesTeam(plugin)?.addEntry(id) + + hitBox.setAI(false) + hitBox.setInvulnerable(true) + hitBox.setCanPickupItems(false) + hitBox.setCollidable(false) + makeInvisible(hitBox) + + this.hitBox = hitBox + } + + fun removeHitbox() { + val hb = hitBox ?: return + val id = hb.uniqueId.toString() + + hb.remove() + if (plugin.shim.supports(9)) getCollidesTeam(plugin)?.removeEntry(id) + + hitBox == null + } + + fun teleportEntity(entity: BukkitEntity?, center: Boolean) { + if (entity == null) return + + val loc = player.location.clone() + if (center) { + loc.x = round(loc.x + 0.5) - 0.5 + loc.y = round(loc.y) + loc.z = round(loc.z + 0.5) - 0.5 + } + + val packet = EntityTeleportPacket(entity, loc) + plugin.server.onlinePlayers.forEach { packet.send(it) } + } + + fun sendBlockUpdate(location: Location?, material: Material) { + if (location == null) return + + val packet = BlockChangePacket(location, material) + plugin.server.onlinePlayers.forEach { + if (it.uniqueId == player.uniqueId) return@forEach + packet.send(it) + } + } + + fun updateVisiblity() { + val block = block ?: return + val show = !isSolid + plugin.server.onlinePlayers.forEach { target -> + if (target.uniqueId == player.uniqueId) return@forEach + + if (show) { + plugin.entityHider.showEntity(target, block) + } else { + plugin.entityHider.hideEntity(target, block) + } + } + } + + fun startSolidifying() { + if (isSolid || hasSolidifyingTask) return + hasSolidifyingTask = true + plugin.server.scheduler.scheduleSyncDelayedTask( + plugin, + { solidifyUpdate(player.location.clone(), 3u) }, + 10, + ) + } + + fun solidifyUpdate(lastLocation: Location, time: UInt) { + val location = player.location + + if ((lastLocation.world != location.world) || (lastLocation.distance(location) > 0.1)) { + hasSolidifyingTask = false + return + } + + // we have solidified! + if (time == 0u) { + ActionBar.clearActionBar(player) + shouldBeSolid = true + hasSolidifyingTask = false + return + } + + // still waiting + ActionBar.sendActionBar(player, "▪".repeat(time.toInt())) + XSound.BLOCK_NOTE_BLOCK_PLING.play(player, 1f, 1f) + + // schedule next update + plugin.server.scheduler.scheduleSyncDelayedTask( + plugin, + { solidifyUpdate(lastLocation, time - 1u) }, + 20, + ) + } +} diff --git a/bukkit/src/disguise/Disguiser.kt b/bukkit/src/disguise/Disguiser.kt new file mode 100644 index 0000000..771c8f6 --- /dev/null +++ b/bukkit/src/disguise/Disguiser.kt @@ -0,0 +1,43 @@ +package cat.freya.khs.bukkit.disguise + +import cat.freya.khs.bukkit.KhsPlugin +import java.util.concurrent.ConcurrentHashMap +import org.bukkit.Material +import org.bukkit.entity.Player as BukkitPlayer + +class Disguiser(val plugin: KhsPlugin) { + val disguises = ConcurrentHashMap<BukkitPlayer, Disguise>() + + fun cleanup() { + disguises.forEach { it.value.remove() } + disguises.clear() + } + + fun getDisguise(player: BukkitPlayer): Disguise? = disguises.get(player) + + fun getByEntityId(id: Int): Disguise? = disguises.values.firstOrNull { it.entityId == id } + + fun getByHitboxId(id: Int): Disguise? = disguises.values.firstOrNull { it.hitBoxId == id } + + fun update() { + for ((player, disguise) in disguises) { + if (!player.isOnline) { + disguise.remove() + disguises.remove(player) + } else { + disguise.update() + } + } + } + + fun disguise(player: BukkitPlayer, material: Material) { + // remove old disguise (if exists) + reveal(player) + // make new one + disguises.put(player, Disguise(plugin, player, material)) + } + + fun reveal(player: BukkitPlayer) { + disguises.remove(player)?.remove() + } +} diff --git a/bukkit/src/disguise/EntityHider.kt b/bukkit/src/disguise/EntityHider.kt new file mode 100644 index 0000000..c3ee9c3 --- /dev/null +++ b/bukkit/src/disguise/EntityHider.kt @@ -0,0 +1,58 @@ +package cat.freya.khs.bukkit.disguise + +import cat.freya.khs.bukkit.packet.EntityDestroyPacket +import cat.freya.khs.bukkit.packet.EntityMetadataPacket +import com.google.common.collect.HashBasedTable +import com.google.common.collect.Table +import org.bukkit.entity.Entity as BukkitEntity +import org.bukkit.entity.Player as BukkitPlayer + +class EntityHider { + val map: Table<Int, Int, Boolean> = HashBasedTable.create() + + // is entity visible for the observer + fun isVisible(observer: BukkitPlayer, entityId: Int): Boolean = + runCatching { + val observerId = observer.entityId + !map.contains(observerId, entityId) + } + .getOrElse { true } + + // is entity visible for the observer + fun isVisible(observer: BukkitPlayer, entity: BukkitEntity): Boolean = + runCatching { isVisible(observer, entity.entityId) }.getOrElse { true } + + // set if the entity is hidden for the observer + fun setVisible(observer: BukkitPlayer, entity: BukkitEntity, visible: Boolean) = runCatching { + val observerId = observer.entityId + val entityId = entity.entityId + val ret = + if (visible) map.put(entityId, observerId, true) else map.remove(entityId, observerId) + ret ?: visible + } + + // removes a hidden entity from the map + fun removeEntity(entity: BukkitEntity) = + runCatching { entity.entityId } + .onSuccess { for (row in map.rowMap().values) row.remove(it) } + + // removes a player from the map + fun removePlayer(player: BukkitPlayer) = + runCatching { player.entityId }.onSuccess { map.rowMap().remove(it) } + + // hides and entity + fun hideEntity(observer: BukkitPlayer, entity: BukkitEntity) { + setVisible(observer, entity, false) + + val packet = EntityDestroyPacket(entity) + packet.send(observer) + } + + // unhides the entity + fun showEntity(observer: BukkitPlayer, entity: BukkitEntity) { + setVisible(observer, entity, true) + + val packet = EntityMetadataPacket(entity, false) + packet.send(observer) + } +} diff --git a/bukkit/src/event/BreakListener.kt b/bukkit/src/event/BreakListener.kt new file mode 100644 index 0000000..832aa72 --- /dev/null +++ b/bukkit/src/event/BreakListener.kt @@ -0,0 +1,56 @@ +package cat.freya.khs.bukkit.event + +import cat.freya.khs.bukkit.BukkitKhsPlayer +import cat.freya.khs.bukkit.KhsPlugin +import cat.freya.khs.event.BreakEvent +import cat.freya.khs.event.onBreak +import org.bukkit.entity.Player as BukkitPlayer +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.block.BlockBreakEvent +import org.bukkit.event.entity.EntityBreakDoorEvent +import org.bukkit.event.hanging.HangingBreakByEntityEvent + +class BreakListener(val plugin: KhsPlugin) : Listener { + + init { + plugin.server.pluginManager.registerEvents(this, plugin) + } + + @EventHandler(priority = EventPriority.HIGHEST) + fun onBlockBreak(event: BlockBreakEvent) { + val bukkitPlayer = event.player ?: return + val block = event.block?.type?.name ?: return + + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + val khsEvent = BreakEvent(plugin.khs, khsPlayer, block) + onBreak(khsEvent) + + if (khsEvent.cancelled) event.setCancelled(true) + } + + @EventHandler(priority = EventPriority.HIGHEST) + fun onEntityBreakDoor(event: EntityBreakDoorEvent) { + val bukkitPlayer = event.entity as? BukkitPlayer ?: return + val block = event.block?.type?.name ?: return + + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + val khsEvent = BreakEvent(plugin.khs, khsPlayer, block) + onBreak(khsEvent) + + if (khsEvent.cancelled) event.setCancelled(true) + } + + @EventHandler(priority = EventPriority.HIGHEST) + fun onHangingBreakByEntity(event: HangingBreakByEntityEvent) { + val bukkitPlayer = event.remover as? BukkitPlayer ?: return + val block = event.entity?.type?.name ?: return + + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + val khsEvent = BreakEvent(plugin.khs, khsPlayer, block) + onBreak(khsEvent) + + if (khsEvent.cancelled) event.setCancelled(true) + } +} diff --git a/bukkit/src/event/ChatListener.kt b/bukkit/src/event/ChatListener.kt new file mode 100644 index 0000000..530fa25 --- /dev/null +++ b/bukkit/src/event/ChatListener.kt @@ -0,0 +1,29 @@ +package cat.freya.khs.bukkit.event + +import cat.freya.khs.bukkit.BukkitKhsPlayer +import cat.freya.khs.bukkit.KhsPlugin +import cat.freya.khs.event.ChatEvent +import cat.freya.khs.event.onChat +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.player.AsyncPlayerChatEvent + +class ChatListener(val plugin: KhsPlugin) : Listener { + + init { + plugin.server.pluginManager.registerEvents(this, plugin) + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + fun onChat(event: AsyncPlayerChatEvent) { + val bukkitPlayer = event.player ?: return + val message = event.message ?: return + + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + val khsEvent = ChatEvent(plugin.khs, khsPlayer, message) + onChat(khsEvent) + + if (khsEvent.cancelled) event.setCancelled(true) + } +} diff --git a/bukkit/src/event/CommandListener.kt b/bukkit/src/event/CommandListener.kt new file mode 100644 index 0000000..7ed25ca --- /dev/null +++ b/bukkit/src/event/CommandListener.kt @@ -0,0 +1,29 @@ +package cat.freya.khs.bukkit.event + +import cat.freya.khs.bukkit.BukkitKhsPlayer +import cat.freya.khs.bukkit.KhsPlugin +import cat.freya.khs.event.CommandEvent +import cat.freya.khs.event.onCommand +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerCommandPreprocessEvent + +class CommandListener(val plugin: KhsPlugin) : Listener { + + init { + plugin.server.pluginManager.registerEvents(this, plugin) + } + + @EventHandler(priority = EventPriority.HIGHEST) + fun onPlayerCommand(event: PlayerCommandPreprocessEvent) { + val bukkitPlayer = event.player ?: return + val message = event.message ?: return + + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + val khsEvent = CommandEvent(plugin.khs, khsPlayer, message) + onCommand(khsEvent) + + if (khsEvent.cancelled) event.setCancelled(true) + } +} diff --git a/bukkit/src/event/DamageListener.kt b/bukkit/src/event/DamageListener.kt new file mode 100644 index 0000000..81d5d12 --- /dev/null +++ b/bukkit/src/event/DamageListener.kt @@ -0,0 +1,56 @@ +package cat.freya.khs.bukkit.event + +import cat.freya.khs.bukkit.BukkitKhsPlayer +import cat.freya.khs.bukkit.KhsPlugin +import cat.freya.khs.event.DamageEvent +import cat.freya.khs.event.onDamage +import org.bukkit.entity.Player as BukkitPlayer +import org.bukkit.entity.Projectile +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.entity.EntityDamageByEntityEvent +import org.bukkit.event.entity.EntityDamageEvent + +class DamageListener(val plugin: KhsPlugin) : Listener { + + init { + plugin.server.pluginManager.registerEvents(this, plugin) + } + + @EventHandler(priority = EventPriority.HIGHEST) + fun onEntityDamageByEntity(event: EntityDamageByEntityEvent) { + val bukkitPlayer = (event.entity as? BukkitPlayer) ?: return + + // get attacker + val damager = event.damager + val attackerEntity: BukkitPlayer? = + when { + damager is Projectile -> damager.shooter as? BukkitPlayer + else -> damager as? BukkitPlayer + } + + if (attackerEntity == null) { + onEntityDamage(event) + return + } + + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + val khsAttacker = BukkitKhsPlayer(plugin.shim, attackerEntity) + val khsEvent = DamageEvent(plugin.khs, khsPlayer, khsAttacker, event.damage) + onDamage(khsEvent) + + if (khsEvent.cancelled) event.setCancelled(true) + } + + @EventHandler(priority = EventPriority.HIGH) + fun onEntityDamage(event: EntityDamageEvent) { + val bukkitPlayer = (event.entity as? BukkitPlayer) ?: return + + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + val khsEvent = DamageEvent(plugin.khs, khsPlayer, null, event.damage) + onDamage(khsEvent) + + if (khsEvent.cancelled) event.setCancelled(true) + } +} diff --git a/bukkit/src/event/InteractListener.kt b/bukkit/src/event/InteractListener.kt new file mode 100644 index 0000000..cc9b2a7 --- /dev/null +++ b/bukkit/src/event/InteractListener.kt @@ -0,0 +1,50 @@ +package cat.freya.khs.bukkit.event + +import cat.freya.khs.bukkit.BukkitKhsPlayer +import cat.freya.khs.bukkit.KhsPlugin +import cat.freya.khs.bukkit.toKhsItem +import cat.freya.khs.event.InteractEvent +import cat.freya.khs.event.UseEvent +import cat.freya.khs.event.onInteract +import cat.freya.khs.event.onUse +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.block.Action +import org.bukkit.event.player.PlayerInteractEvent + +class InteractListener(val plugin: KhsPlugin) : Listener { + + init { + plugin.server.pluginManager.registerEvents(this, plugin) + } + + @EventHandler(priority = EventPriority.HIGHEST) + fun onPlayerInteract(event: PlayerInteractEvent) { + val bukkitPlayer = event.player ?: return + + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + + val block = event.clickedBlock?.type?.name + if (event.action == Action.RIGHT_CLICK_BLOCK && block != null) { + val khsEvent = InteractEvent(plugin.khs, khsPlayer, block) + onInteract(khsEvent) + + if (khsEvent.cancelled) { + event.setCancelled(true) + return + } + } + + val item = toKhsItem(event.item) + if (item != null) { + val khsEvent = UseEvent(plugin.khs, khsPlayer, item) + onUse(khsEvent) + + if (khsEvent.cancelled) { + event.setCancelled(true) + return + } + } + } +} diff --git a/bukkit/src/event/InventoryListener.kt b/bukkit/src/event/InventoryListener.kt new file mode 100644 index 0000000..4ec0a83 --- /dev/null +++ b/bukkit/src/event/InventoryListener.kt @@ -0,0 +1,61 @@ +package cat.freya.khs.bukkit.event + +import cat.freya.khs.bukkit.BukkitKhsInventory +import cat.freya.khs.bukkit.BukkitKhsPlayer +import cat.freya.khs.bukkit.KhsPlugin +import cat.freya.khs.bukkit.toKhsItem +import cat.freya.khs.event.ClickEvent +import cat.freya.khs.event.CloseEvent +import cat.freya.khs.event.onClick +import cat.freya.khs.event.onClose +import org.bukkit.entity.Player as BukkitPlayer +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.event.inventory.InventoryEvent +import org.bukkit.inventory.Inventory + +class InventoryListener(val plugin: KhsPlugin) : Listener { + + init { + plugin.server.pluginManager.registerEvents(this, plugin) + } + + private fun getInv(event: InventoryEvent): Pair<Inventory, String?> { + if (plugin.shim.supports(14)) { + var inv = event.view.topInventory + return inv to event.view.title + } else { + var inv = event.inventory + var title = inv::class.java.getMethod("getName").invoke(inv) as String + return inv to title + } + } + + @EventHandler(priority = EventPriority.HIGHEST) + fun onInventoryClick(event: InventoryClickEvent) { + val (inventory, title) = getInv(event) + val bukkitPlayer = event.whoClicked as? BukkitPlayer ?: return + val item = toKhsItem(event.currentItem) ?: return + + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + val khsInventory = BukkitKhsInventory(plugin.shim, inventory, title) + val khsEvent = ClickEvent(plugin.khs, khsPlayer, khsInventory, item) + onClick(khsEvent) + + if (khsEvent.cancelled) event.setCancelled(true) + } + + @EventHandler(priority = EventPriority.HIGHEST) + fun onInventoryClose(event: InventoryCloseEvent) { + val (inventory, title) = getInv(event) + val bukkitPlayer = event.player as? BukkitPlayer ?: return + + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + val khsInventory = BukkitKhsInventory(plugin.shim, inventory, title) + val khsEvent = CloseEvent(plugin.khs, khsPlayer, khsInventory) + onClose(khsEvent) + } +} diff --git a/bukkit/src/event/JoinLeaveListener.kt b/bukkit/src/event/JoinLeaveListener.kt new file mode 100644 index 0000000..3bb2832 --- /dev/null +++ b/bukkit/src/event/JoinLeaveListener.kt @@ -0,0 +1,52 @@ +package cat.freya.khs.bukkit.event + +import cat.freya.khs.bukkit.BukkitKhsPlayer +import cat.freya.khs.bukkit.KhsPlugin +import cat.freya.khs.event.JoinEvent +import cat.freya.khs.event.KickEvent +import cat.freya.khs.event.LeaveEvent +import cat.freya.khs.event.onJoin +import cat.freya.khs.event.onKick +import cat.freya.khs.event.onLeave +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerJoinEvent +import org.bukkit.event.player.PlayerKickEvent +import org.bukkit.event.player.PlayerQuitEvent + +class JoinLeaveListener(val plugin: KhsPlugin) : Listener { + + init { + plugin.server.pluginManager.registerEvents(this, plugin) + } + + @EventHandler(priority = EventPriority.HIGHEST) + fun onPlayerJoin(event: PlayerJoinEvent) { + val bukkitPlayer = event.player ?: return + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + val khsEvent = JoinEvent(plugin.khs, khsPlayer) + onJoin(khsEvent) + } + + @EventHandler(priority = EventPriority.HIGHEST) + fun onPlayerQuit(event: PlayerQuitEvent) { + val bukkitPlayer = event.player ?: return + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + val khsEvent = LeaveEvent(plugin.khs, khsPlayer) + onLeave(khsEvent) + + // remove player from disguiser + plugin.entityHider.removePlayer(bukkitPlayer) + } + + @EventHandler(priority = EventPriority.HIGHEST) + fun onPlayerKick(event: PlayerKickEvent) { + val bukkitPlayer = event.player ?: return + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + val khsEvent = KickEvent(plugin.khs, khsPlayer, event.reason ?: "") + onKick(khsEvent) + + if (khsEvent.cancelled) event.setCancelled(true) + } +} diff --git a/bukkit/src/event/MovementListener.kt b/bukkit/src/event/MovementListener.kt new file mode 100644 index 0000000..9b80415 --- /dev/null +++ b/bukkit/src/event/MovementListener.kt @@ -0,0 +1,71 @@ +package cat.freya.khs.bukkit.event + +import cat.freya.khs.bukkit.BukkitKhsPlayer +import cat.freya.khs.bukkit.KhsPlugin +import cat.freya.khs.event.JumpEvent +import cat.freya.khs.event.MoveEvent +import cat.freya.khs.event.onJump +import cat.freya.khs.event.onMove +import cat.freya.khs.world.Position as KhsPosition +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import org.bukkit.Material +import org.bukkit.entity.Player as BukkitPlayer +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerMoveEvent + +class MovementListener(val plugin: KhsPlugin) : Listener { + + private val prevPlayersOnGround: MutableSet<UUID> = ConcurrentHashMap.newKeySet<UUID>() + + init { + plugin.server.pluginManager.registerEvents(this, plugin) + } + + private fun isOnGround(player: BukkitPlayer): Boolean { + if (plugin.shim.supports(16, 1)) { + val below = player.location.clone().subtract(0.0, 0.1, 0.0).block + return below.type.isSolid + } else { + @Suppress("DEPRECATION") + return player.isOnGround() + } + } + + @EventHandler(priority = EventPriority.HIGHEST) + fun onPlayerMove(event: PlayerMoveEvent) { + val bukkitPlayer = event.player ?: return + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + + // check jumping + if (bukkitPlayer.velocity.y > 0.0) { + val block = bukkitPlayer.location?.block?.type + if ( + block != Material.LADDER && + prevPlayersOnGround.contains(bukkitPlayer.uniqueId) && + isOnGround(bukkitPlayer) + ) { + // trigger jump event + val khsEvent = JumpEvent(plugin.khs, khsPlayer) + onJump(khsEvent) + } + // update set + if (isOnGround(bukkitPlayer)) prevPlayersOnGround.add(bukkitPlayer.uniqueId) + else prevPlayersOnGround.remove(bukkitPlayer.uniqueId) + } + + val to = event.to?.let { KhsPosition(it.x, it.y, it.z) } ?: return + val khsEvent = MoveEvent(plugin.khs, khsPlayer, to) + onMove(khsEvent) + + if (khsEvent.cancelled) event.setCancelled(true) + + // update disguise (if exists) + val disguise = plugin.disguiser.getDisguise(bukkitPlayer) ?: return + val dest = event.to ?: return + if (!khsEvent.cancelled && event.from.distance(dest) > 0.1) disguise.shouldBeSolid = false + disguise.startSolidifying() + } +} diff --git a/bukkit/src/event/PacketListener.kt b/bukkit/src/event/PacketListener.kt new file mode 100644 index 0000000..d49af60 --- /dev/null +++ b/bukkit/src/event/PacketListener.kt @@ -0,0 +1,110 @@ +package cat.freya.khs.bukkit.event + +import cat.freya.khs.bukkit.BukkitKhsPlayer +import cat.freya.khs.bukkit.KhsPlugin +import cat.freya.khs.bukkit.disguise.Disguise +import cat.freya.khs.event.DamageEvent +import cat.freya.khs.event.onDamage +import com.github.retrooper.packetevents.PacketEvents +import com.github.retrooper.packetevents.event.PacketListener as PacketListenerPE +import com.github.retrooper.packetevents.event.PacketListenerPriority +import com.github.retrooper.packetevents.event.PacketReceiveEvent +import com.github.retrooper.packetevents.event.PacketSendEvent +import com.github.retrooper.packetevents.protocol.packettype.PacketType.Play.Client.INTERACT_ENTITY +import com.github.retrooper.packetevents.protocol.packettype.PacketType.Play.Server.* +import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientInteractEntity +import com.github.retrooper.packetevents.wrapper.play.server.* +import java.util.UUID +import org.bukkit.GameMode +import org.bukkit.attribute.Attribute +import org.bukkit.entity.Player as BukkitPlayer + +class PacketListener(val plugin: KhsPlugin) : PacketListenerPE { + private val debounce = mutableSetOf<UUID>() + + init { + PacketEvents.getAPI().eventManager.registerListener(this, PacketListenerPriority.NORMAL) + } + + // intercept entity-related packets of entities that + // are supposed to be hidden + override fun onPacketSend(event: PacketSendEvent) { + val player = event.getPlayer() as? BukkitPlayer ?: return + val entityId = + when (event.packetType) { + ENTITY_EQUIPMENT -> WrapperPlayServerEntityEquipment(event).entityId + ENTITY_ANIMATION -> WrapperPlayServerEntityAnimation(event).entityId + SPAWN_ENTITY -> WrapperPlayServerSpawnEntity(event).entityId + ENTITY_VELOCITY -> WrapperPlayServerEntityVelocity(event).entityId + ENTITY_HEAD_LOOK -> WrapperPlayServerEntityHeadLook(event).entityId + ENTITY_TELEPORT -> WrapperPlayServerEntityTeleport(event).entityId + ENTITY_STATUS -> WrapperPlayServerEntityStatus(event).entityId + ENTITY_METADATA -> WrapperPlayServerEntityMetadata(event).entityId + ENTITY_EFFECT -> WrapperPlayServerEntityEffect(event).entityId + REMOVE_ENTITY_EFFECT -> WrapperPlayServerRemoveEntityEffect(event).entityId + else -> return + } + + if (!plugin.entityHider.isVisible(player, entityId)) { + event.setCancelled(true) + } + } + + // check when a player is trying to attack a disguise + override fun onPacketReceive(event: PacketReceiveEvent) { + val player = event.getPlayer() as? BukkitPlayer ?: return + + // we want interact event + if (event.packetType != INTERACT_ENTITY) return + + val packet = WrapperPlayClientInteractEntity(event) + + // attacking only + val action = packet.action ?: return + if (action != WrapperPlayClientInteractEntity.InteractAction.ATTACK) return + + val disguise = + plugin.disguiser.getByEntityId(packet.entityId) + ?: plugin.disguiser.getByHitboxId(packet.entityId) + ?: return + + if (disguise.player.gameMode == GameMode.CREATIVE) return + + event.setCancelled(true) + handleAttack(disguise, player) + } + + private fun handleAttack(disguise: Disguise, seeker: BukkitPlayer) { + if (disguise.player.uniqueId == seeker.uniqueId) return + + val fallbackAmount = 7.0 + val amount = + if (plugin.shim.supports(9)) { + val attribName = + if (plugin.shim.supports(21)) "ATTACK_DAMAGE" else "GENERIC_ATTACK_DAMAGE" + val attrib = Attribute.valueOf(attribName) + seeker.getAttribute(attrib)?.value ?: fallbackAmount + } else { + fallbackAmount // uhhh i dunno how to do this for 1.8 + } + + val debounceUUID = disguise.player.uniqueId + + disguise.shouldBeSolid = false + if (debounce.contains(debounceUUID)) return + + // trigger an attack event + val khsPlayer = BukkitKhsPlayer(plugin.shim, disguise.player) + val khsSeeker = BukkitKhsPlayer(plugin.shim, seeker) + val khsEvent = DamageEvent(plugin.khs, khsPlayer, khsSeeker, amount) + onDamage(khsEvent) + + // set and soon turn off debounce + debounce.add(debounceUUID) + plugin.server.scheduler.scheduleSyncDelayedTask( + plugin, + { debounce.remove(debounceUUID) }, + 10, + ) + } +} diff --git a/bukkit/src/event/PlayerListener.kt b/bukkit/src/event/PlayerListener.kt new file mode 100644 index 0000000..23b0a91 --- /dev/null +++ b/bukkit/src/event/PlayerListener.kt @@ -0,0 +1,59 @@ +package cat.freya.khs.bukkit.event + +import cat.freya.khs.bukkit.BukkitKhsPlayer +import cat.freya.khs.bukkit.KhsPlugin +import cat.freya.khs.bukkit.toKhsItem +import cat.freya.khs.event.DropEvent +import cat.freya.khs.event.HungerEvent +import cat.freya.khs.event.RegenEvent +import cat.freya.khs.event.onDrop +import cat.freya.khs.event.onHunger +import cat.freya.khs.event.onRegen +import org.bukkit.entity.Player as BukkitPlayer +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.entity.EntityRegainHealthEvent +import org.bukkit.event.entity.FoodLevelChangeEvent +import org.bukkit.event.player.PlayerDropItemEvent + +class PlayerListener(val plugin: KhsPlugin) : Listener { + + init { + plugin.server.pluginManager.registerEvents(this, plugin) + } + + @EventHandler(priority = EventPriority.HIGHEST) + fun onFoodLevelChange(event: FoodLevelChangeEvent) { + val bukkitPlayer = event.entity as? BukkitPlayer ?: return + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + val khsEvent = HungerEvent(plugin.khs, khsPlayer) + onHunger(khsEvent) + + if (khsEvent.cancelled) event.setCancelled(true) + } + + @EventHandler(priority = EventPriority.HIGHEST) + fun onEntityRegainHealth(event: EntityRegainHealthEvent) { + val bukkitPlayer = event.entity as? BukkitPlayer ?: return + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + val natural = + event.regainReason == EntityRegainHealthEvent.RegainReason.SATIATED || + event.regainReason == EntityRegainHealthEvent.RegainReason.REGEN + val khsEvent = RegenEvent(plugin.khs, khsPlayer, natural) + onRegen(khsEvent) + + if (khsEvent.cancelled) event.setCancelled(true) + } + + @EventHandler(priority = EventPriority.HIGHEST) + fun onPlayerDropItem(event: PlayerDropItemEvent) { + val bukkitPlayer = event.player ?: return + val item = toKhsItem(event.itemDrop?.itemStack) ?: return + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + val khsEvent = DropEvent(plugin.khs, khsPlayer, item) + onDrop(khsEvent) + + if (khsEvent.cancelled) event.setCancelled(true) + } +} diff --git a/bukkit/src/event/RespawnListener.kt b/bukkit/src/event/RespawnListener.kt new file mode 100644 index 0000000..a2b4bc5 --- /dev/null +++ b/bukkit/src/event/RespawnListener.kt @@ -0,0 +1,41 @@ +package cat.freya.khs.bukkit.event + +import cat.freya.khs.bukkit.BukkitKhsPlayer +import cat.freya.khs.bukkit.KhsPlugin +import cat.freya.khs.event.DeathEvent +import cat.freya.khs.event.onDeath +import cat.freya.khs.world.Location +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.entity.PlayerDeathEvent +import org.bukkit.event.player.PlayerRespawnEvent + +class RespawnListener(val plugin: KhsPlugin) : Listener { + + private val respawnLocation: MutableMap<UUID, Location> = ConcurrentHashMap<UUID, Location>() + + init { + plugin.server.pluginManager.registerEvents(this, plugin) + } + + @EventHandler(priority = EventPriority.HIGHEST) + fun onPlayerDeath(event: PlayerDeathEvent) { + val bukkitPlayer = event.entity ?: return + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + val khsEvent = DeathEvent(plugin.khs, khsPlayer) + onDeath(khsEvent) + + if (khsEvent.cancelled) respawnLocation[khsPlayer.uuid] = khsPlayer.location + } + + @EventHandler(priority = EventPriority.HIGHEST) + fun onPlayerRespawn(event: PlayerRespawnEvent) { + val bukkitPlayer = event.player ?: return + val khsPlayer = BukkitKhsPlayer(plugin.shim, bukkitPlayer) + val location = respawnLocation.remove(khsPlayer.uuid) ?: return + khsPlayer.teleport(location) + } +} diff --git a/bukkit/src/packet/BlockChangePacket.kt b/bukkit/src/packet/BlockChangePacket.kt new file mode 100644 index 0000000..7d11f4e --- /dev/null +++ b/bukkit/src/packet/BlockChangePacket.kt @@ -0,0 +1,20 @@ +package cat.freya.khs.bukkit.packet + +import com.github.retrooper.packetevents.PacketEvents +import com.github.retrooper.packetevents.util.Vector3i +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerBlockChange +import io.github.retrooper.packetevents.util.SpigotConversionUtil +import org.bukkit.Bukkit +import org.bukkit.Location +import org.bukkit.Material +import org.bukkit.entity.Player as BukkitPlayer + +data class BlockChangePacket(val location: Location, val material: Material) { + fun send(player: BukkitPlayer) { + val blockData = Bukkit.createBlockData(material) + val state = SpigotConversionUtil.fromBukkitBlockData(blockData) + val vector = Vector3i(location.blockX, location.blockY, location.blockZ) + val packet = WrapperPlayServerBlockChange(vector, state) + PacketEvents.getAPI().playerManager.sendPacket(player, packet) + } +} diff --git a/bukkit/src/packet/EntityDestroyPacket.kt b/bukkit/src/packet/EntityDestroyPacket.kt new file mode 100644 index 0000000..3240b81 --- /dev/null +++ b/bukkit/src/packet/EntityDestroyPacket.kt @@ -0,0 +1,13 @@ +package cat.freya.khs.bukkit.packet + +import com.github.retrooper.packetevents.PacketEvents +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerDestroyEntities +import org.bukkit.entity.Entity +import org.bukkit.entity.Player as BukkitPlayer + +data class EntityDestroyPacket(val entiy: Entity) { + fun send(player: BukkitPlayer) { + val packet = WrapperPlayServerDestroyEntities(entiy.entityId) + PacketEvents.getAPI().playerManager.sendPacket(player, packet) + } +} diff --git a/bukkit/src/packet/EntityMetadataPacket.kt b/bukkit/src/packet/EntityMetadataPacket.kt new file mode 100644 index 0000000..6d5978a --- /dev/null +++ b/bukkit/src/packet/EntityMetadataPacket.kt @@ -0,0 +1,17 @@ +package cat.freya.khs.bukkit.packet + +import com.github.retrooper.packetevents.PacketEvents +import com.github.retrooper.packetevents.protocol.entity.data.EntityData +import com.github.retrooper.packetevents.protocol.entity.data.EntityDataTypes +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityMetadata +import org.bukkit.entity.Entity +import org.bukkit.entity.Player as BukkitPlayer + +data class EntityMetadataPacket(val entiy: Entity, val glow: Boolean) { + fun send(player: BukkitPlayer) { + val glowingByte = if (glow) 0x40 else 0x0 + val data = EntityData(0x0, EntityDataTypes.BYTE, glowingByte.toByte()) + val packet = WrapperPlayServerEntityMetadata(entiy.entityId, listOf(data)) + PacketEvents.getAPI().playerManager.sendPacket(player, packet) + } +} diff --git a/bukkit/src/packet/EntityTeleportPacket.kt b/bukkit/src/packet/EntityTeleportPacket.kt new file mode 100644 index 0000000..7e3beb1 --- /dev/null +++ b/bukkit/src/packet/EntityTeleportPacket.kt @@ -0,0 +1,19 @@ +package cat.freya.khs.bukkit.packet + +import com.github.retrooper.packetevents.PacketEvents +import com.github.retrooper.packetevents.util.Vector3d +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityTeleport +import org.bukkit.Location +import org.bukkit.entity.Entity +import org.bukkit.entity.Player as BukkitPlayer + +data class EntityTeleportPacket(val entity: Entity, val position: Location) { + fun send(player: BukkitPlayer) { + val vector = Vector3d(position.x, position.y, position.z) + val yaw = 0f + val pitch = 0f + val onGround = false + val packet = WrapperPlayServerEntityTeleport(entity.entityId, vector, yaw, pitch, onGround) + PacketEvents.getAPI().playerManager.sendPacket(player, packet) + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 0000000..6070b2d --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,22 @@ +dependencies { + // core libs + implementation("org.yaml:snakeyaml:2.6") + implementation(kotlin("reflect")) + + // orm + implementation("org.jetbrains.exposed:exposed-core:1.1.1") + implementation("org.jetbrains.exposed:exposed-jdbc:1.1.1") + + // database + implementation("org.xerial:sqlite-jdbc:3.51.3.0") + implementation("com.mysql:mysql-connector-j:9.6.0") + implementation("org.postgresql:postgresql:42.7.10") + implementation("com.zaxxer:HikariCP:7.0.2") +} + +kotlin { + sourceSets.main { + kotlin.srcDirs("src") + resources.srcDirs("res") + } +} diff --git a/core/src/Checks.kt b/core/src/Checks.kt new file mode 100644 index 0000000..cc3c4c7 --- /dev/null +++ b/core/src/Checks.kt @@ -0,0 +1,187 @@ +package cat.freya.khs + +import cat.freya.khs.game.Game +import cat.freya.khs.game.KhsMap +import cat.freya.khs.player.Player +import cat.freya.khs.world.Position + +class Checks(val plugin: Khs, val player: Player) { + /// checks if there exists a map that is setup + fun gameMapExists() { + if (plugin.game.selectMap() == null) { + val msg = + if (plugin.maps.isEmpty()) plugin.locale.map.none else plugin.locale.map.noneSetup + error(msg) + } + } + + /// checks that the game is in progress + fun gameInProgress() { + if (!plugin.game.status.inProgress()) { + error(plugin.locale.game.notInProgress) + } + } + + /// checks that the game is not in progress + fun gameNotInProgress() { + if (plugin.game.status != Game.Status.LOBBY) { + error(plugin.locale.game.inProgress) + } + } + + /// checks that the caller is in the game + fun playerNotInGame() { + if (plugin.game.hasPlayer(player)) { + error(plugin.locale.game.inGame) + } + } + + /// checks that the caller is in the game + fun playerInGame() { + if (!plugin.game.hasPlayer(player)) { + error(plugin.locale.game.notInGame) + } + } + + /// check if the lobby has enough players to start + fun lobbyHasEnoughPlayers() { + if (plugin.game.size < plugin.config.minPlayers) { + error(plugin.locale.lobby.notEnoughPlayers.with(plugin.config.minPlayers)) + } + } + + /// check if the lobby is empty + fun lobbyEmpty() { + if (plugin.game.size > 0u) { + error(plugin.locale.lobby.inUse) + } + } + + /// cheks that the player is in the game world + fun inMapWorld(mapName: String) { + inMapWorld(plugin.maps.get(mapName)) + } + + /// cheks that the player is in the game world + fun inMapWorld(map: KhsMap?) { + if (map?.worldName != player.location.worldName) error(plugin.locale.map.wrongWorld) + } + + /// Checks that the map exists and is setup + fun mapSetup(map: KhsMap?) { + if (map == null) error(plugin.locale.map.unknown) + if (!map.setup) error(plugin.locale.map.setup.not.with(map.name)) + } + + /// Checks if the map has bounds + fun mapHasBounds(map: KhsMap?) { + if (map == null) error(plugin.locale.map.unknown) + if (map.bounds() == null) error(plugin.locale.map.error.bounds) + } + + /// Checks if the map has bounds + fun mapHasBounds(name: String) { + mapHasBounds(plugin.maps.get(name)) + } + + /// Checks if a map exists + fun mapExists(name: String) { + mapExists(plugin.maps.get(name)) + } + + /// Checks if a map exists + fun mapExists(map: KhsMap?) { + if (map == null) error(plugin.locale.map.unknown) + } + + /// Checks if a map doesnt exists + fun mapDoesNotExist(name: String) { + if (plugin.maps.containsKey(name)) error(plugin.locale.map.exists) + } + + /// Checks if a map name is valid + fun mapNameValid(name: String) { + if (!name.matches(Regex("[a-zA-Z0-9]*")) || name.isEmpty()) + error(plugin.locale.map.invalidName) + } + + /// Checks if a world exists + fun worldExists(worldName: String) { + if (!plugin.shim.worlds.contains(worldName)) + error(plugin.locale.world.doesntExist.with(worldName)) + } + + /// Checks if a world doesnt exists + fun worldDoesNotExist(worldName: String) { + if (plugin.shim.worlds.contains(worldName)) + error(plugin.locale.world.exists.with(worldName)) + } + + /// Checks if a world is valid for a map + fun worldValid(worldName: String) { + worldExists(worldName) + if (worldName.startsWith("hs_")) error(plugin.locale.world.doesntExist.with(worldName)) + } + + /// Checks that a world is not in use + fun worldNotInUse(worldName: String) { + val map = + plugin.maps.values.find { it.worldName == worldName || it.gameWorldName == worldName } + if (map != null) error(plugin.locale.world.inUseBy.with(worldName, map.name)) + if (plugin.config.exit?.worldName == worldName) + error(plugin.locale.world.inUse.with(worldName)) + } + + /// Checks if blockhunt is supported + fun blockHuntSupported() { + if (!plugin.shim.supports(9)) error(plugin.locale.blockHunt.notSupported) + } + + /// Checks if a map has block hunt enabled + fun blockHuntEnabled(name: String) { + mapExists(name) + val map = plugin.maps.get(name) ?: return + if (!map.config.blockHunt.enabled) error(plugin.locale.blockHunt.notEnabled) + } + + private fun isSpawnInRange(map: KhsMap, position: Position?): Boolean { + if (position == null) return true // return true to not reset a null value + + // check world border (with in 100 blocks) + val border = map.config.worldBorder + if (border.enabled && border.pos?.distance(position) ?: 0.0 > 100.0) return false + + // check in bounds + if (map.bounds()?.inBounds(position.x, position.z) == false) return false + + return true + } + + /// Makes sure a spawn is in gane + fun spawnInRange(map: KhsMap, pos: Position) { + if (!isSpawnInRange(map, pos)) error(plugin.locale.map.error.notInRange) + } + + /// Makes sure spawns are in range + fun spawnsInRange(map: KhsMap) { + // check game spawn + if (!isSpawnInRange(map, map.gameSpawn?.position)) { + player.message(plugin.locale.prefix.warning + plugin.locale.map.warn.gameSpawnReset) + map.gameSpawn = null + } + // check seeker spawn + if (!isSpawnInRange(map, map.seekerLobbySpawn?.position)) { + player.message(plugin.locale.prefix.warning + plugin.locale.map.warn.seekerSpawnReset) + map.seekerLobbySpawn = null + } + // check lobby spawn + if (!isSpawnInRange(map, map.lobbySpawn?.position)) { + player.message(plugin.locale.prefix.warning + plugin.locale.map.warn.lobbySpawnReset) + map.lobbySpawn = null + } + } +} + +fun runChecks(plugin: Khs, player: Player, fn: Checks.() -> Unit) { + fn(Checks(plugin, player)) +} diff --git a/core/src/Khs.kt b/core/src/Khs.kt new file mode 100644 index 0000000..6f82229 --- /dev/null +++ b/core/src/Khs.kt @@ -0,0 +1,170 @@ +package cat.freya.khs + +import cat.freya.khs.command.* +import cat.freya.khs.command.map.* +import cat.freya.khs.command.map.blockhunt.* +import cat.freya.khs.command.map.blockhunt.block.* +import cat.freya.khs.command.map.set.* +import cat.freya.khs.command.map.unset.* +import cat.freya.khs.command.util.CommandGroup +import cat.freya.khs.command.world.* +import cat.freya.khs.config.KhsBoardConfig +import cat.freya.khs.config.KhsConfig +import cat.freya.khs.config.KhsItemsConfig +import cat.freya.khs.config.KhsLocale +import cat.freya.khs.config.KhsMapsConfig +import cat.freya.khs.config.util.deserialize +import cat.freya.khs.config.util.serialize +import cat.freya.khs.db.Database +import cat.freya.khs.game.Game +import cat.freya.khs.game.KhsMap +import java.util.concurrent.ConcurrentHashMap + +/// Plugin wrapper +class Khs(val shim: KhsShim) { + + @Volatile var config: KhsConfig = KhsConfig() + @Volatile var itemsConfig: KhsItemsConfig = KhsItemsConfig() + @Volatile var boardConfig: KhsBoardConfig = KhsBoardConfig() + @Volatile var locale: KhsLocale = KhsLocale() + + // code should access maps.<name>.config instead + private var mapsConfig: KhsMapsConfig = KhsMapsConfig() + + val game: Game = Game(this) + val maps: MutableMap<String, KhsMap> = ConcurrentHashMap<String, KhsMap>() + @Volatile var database: Database? = null + + val commandGroup: CommandGroup = registerCommands() + + // if we are performing a map save right now + @Volatile var saving: Boolean = false + + fun init() { + shim.logger.info(" _ ___ _ ____") + shim.logger.info("| |/ / | | / ___|") + shim.logger.info("| ' /| |_| \\___ \\") + shim.logger.info("| . \\| _ |___) |") + shim.logger.info("|_|\\_\\_| |_|____/") + + val mcVersion = shim.mcVersion.joinToString(".") + shim.logger.info("Version ${shim.pluginVersion} running on ${mcVersion}-${shim.platform}") + + reloadConfig() + .onFailure { + shim.logger.warning("Plugin loaded with errors :(") + shim.disable() + } + .onSuccess { + shim.logger.info("Plugin loaded successfully!") + saveConfig() + } + } + + fun cleanup() { + for (uuid in game.UUIDs) game.leave(uuid) + } + + fun registerCommands(): CommandGroup { + return CommandGroup( + this, + "hs", + KhsConfirm(), + KhsDebug(), + KhsHelp(), + KhsJoin(), + KhsLeave(), + KhsReload(), + KhsSend(), + KhsSetExit(), + KhsStart(), + KhsStop(), + KhsTop(), + KhsWins(), + CommandGroup( + this, + "map", + KhsMapAdd(), + KhsMapGoTo(), + KhsMapList(), + KhsMapRemove(), + KhsMapSave(), + KhsMapStatus(), + CommandGroup( + this, + "blockhunt", + KhsMapBlockHuntDebug(), + KhsMapBlockHuntEnabled(), + CommandGroup( + this, + "block", + KhsMapBlockHuntBlockAdd(), + KhsMapBlockHuntBlockList(), + KhsMapBlockHuntBlockRemove(), + ), + ), + CommandGroup( + this, + "set", + KhsMapSetBorder(), + KhsMapSetBounds(), + KhsMapSetLobby(), + KhsMapSetSeekerLobby(), + KhsMapSetSpawn(), + ), + CommandGroup(this, "unset", KhsMapUnsetBorder()), + ), + CommandGroup( + this, + "world", + KhsWorldCreate(), + KhsWorldDelete(), + KhsWorldList(), + KhsWorldTp(), + ), + ) + } + + fun reloadConfig(): Result<Unit> = + runCatching { + shim.logger.info("Loading config...") + config = deserialize(KhsConfig::class, shim.readConfigFile("config.yml")) + shim.logger.info("Loading items...") + itemsConfig = deserialize(KhsItemsConfig::class, shim.readConfigFile("items.yml")) + shim.logger.info("Loading maps...") + mapsConfig = deserialize(KhsMapsConfig::class, shim.readConfigFile("maps.yml")) + shim.logger.info("Loading board locale...") + boardConfig = deserialize(KhsBoardConfig::class, shim.readConfigFile("board.yml")) + shim.logger.info("Loading locale...") + locale = deserialize(KhsLocale::class, shim.readConfigFile("locale.yml")) + shim.logger.info("Loading database...") + database = Database(this) + + // reload maps + // we need a seperate newMaps, in case one of the maps below fails + // to load + val newMaps = + mapsConfig.maps.mapValues { (name, mapConfig) -> KhsMap(name, mapConfig, this) } + + game.setMap(null) + maps.clear() + newMaps.forEach { maps[it.key] = it.value } + } + .onFailure { shim.logger.error("failed to reload config: ${it.message}") } + + fun saveConfig() { + runCatching { + val newMapsConfig = KhsMapsConfig(maps.mapValues { it.value.config }) + shim.writeConfigFile("config.yml", serialize(config)) + shim.writeConfigFile("items.yml", serialize(itemsConfig)) + shim.writeConfigFile("maps.yml", serialize(newMapsConfig)) + shim.writeConfigFile("board.yml", serialize(boardConfig)) + shim.writeConfigFile("locale.yml", serialize(locale)) + } + .onFailure { shim.logger.error("failed to save config: ${it.message}") } + } + + fun onTick() { + game.doTick() + } +} diff --git a/core/src/KhsShim.kt b/core/src/KhsShim.kt new file mode 100644 index 0000000..9a31523 --- /dev/null +++ b/core/src/KhsShim.kt @@ -0,0 +1,99 @@ +package cat.freya.khs + +import cat.freya.khs.config.EffectConfig +import cat.freya.khs.config.ItemConfig +import cat.freya.khs.game.Board +import cat.freya.khs.player.Inventory +import cat.freya.khs.player.Player +import cat.freya.khs.world.Effect +import cat.freya.khs.world.Item +import cat.freya.khs.world.World +import java.io.InputStream +import java.util.UUID + +// Logger wrapper +// (different baselines may use different logging systems) +interface Logger { + fun info(message: String) + + fun warning(message: String) + + fun error(message: String) +} + +// Plugin wrapper +interface KhsShim { + /// @returns the string of the plugin version + val pluginVersion: String + + /// @returns the release minecraft version (ignores the 1.) + val mcVersion: List<UInt> + + /// the platform this shim is for + val platform: String + + /// @returns the logger + val logger: Logger + + /// @returns list of online players + val players: List<Player> + + /// @returns list of world names + val worlds: List<String> + + /// were the khs.db is stored + val sqliteDatabasePath: String + + /// @returns a stream from a file in the systems config dir + fun readConfigFile(fileName: String): InputStream? + + /// write a config file + fun writeConfigFile(fileName: String, content: String) + + /// @returns a valid material for the current mc version given the name + fun parseMaterial(materialName: String): String? + + /// @returns a valid item given the config + fun parseItem(itemConfig: ItemConfig): Item? + + /// @returns a valid item given the config + fun parseEffect(effectConfig: EffectConfig): Effect? + + /// @returns a player that is online on the server right now + fun getPlayer(uuid: UUID): Player? + + fun getPlayer(name: String): Player? + + /// @returns a world on the server that exists with the given world name + fun getWorld(worldName: String): World? + + /// @returns a manager to load/unload a world + fun getWorldLoader(worldName: String): World.Loader + + /// create a new world + fun createWorld(worldName: String, type: World.Type): World? + + /// create a inventory to use for a player + fun createInventory(title: String, size: UInt): Inventory? + + /// @returns a new board + fun getBoard(name: String): Board? + + /// broadcast a message to everyone + fun broadcast(message: String) + + /// disable everything + fun disable() + + /// schedule an event to run at a later date + fun scheduleEvent(ticks: ULong, event: () -> Unit) + + fun supports(vararg versions: Int): Boolean { + val seq = versions.asSequence().map { it.toUInt() }.zip(mcVersion.asSequence()).toList() + for ((want, has) in seq) { + if (want < has) return true + if (want > has) return false + } + return true + } +} diff --git a/core/src/Request.kt b/core/src/Request.kt new file mode 100644 index 0000000..152be7b --- /dev/null +++ b/core/src/Request.kt @@ -0,0 +1,12 @@ +package cat.freya.khs + +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +data class Request(val fn: () -> Unit, val lengthSeconds: Long) { + val start = System.currentTimeMillis() + val expired: Boolean + get() = (System.currentTimeMillis() - start) < lengthSeconds * 1000 +} + +val REQUESTS: MutableMap<UUID, Request> = ConcurrentHashMap<UUID, Request>() diff --git a/core/src/command/Confirm.kt b/core/src/command/Confirm.kt new file mode 100644 index 0000000..359f490 --- /dev/null +++ b/core/src/command/Confirm.kt @@ -0,0 +1,32 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.REQUESTS +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player + +class KhsConfirm : Command { + override val label = "confirm" + override val usage = listOf<String>() + override val description = "Confirm a request of a previously run command" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val request = REQUESTS.remove(player.uuid) + + if (request == null) { + player.message(plugin.locale.prefix.error + plugin.locale.confirm.none) + return + } + + if (request.expired) { + player.message(plugin.locale.prefix.error + plugin.locale.confirm.timedOut) + return + } + + request.fn() + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf() + } +} diff --git a/core/src/command/Debug.kt b/core/src/command/Debug.kt new file mode 100644 index 0000000..d000d60 --- /dev/null +++ b/core/src/command/Debug.kt @@ -0,0 +1,24 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.inv.createDebugMenu +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsDebug : Command { + override val label = "debug" + override val usage = listOf<String>() + override val description = "Mess with/debug the current game" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + runChecks(plugin, player) { gameMapExists() } + + val inv = createDebugMenu(plugin) ?: return + player.showInventory(inv) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf() + } +} diff --git a/core/src/command/Help.kt b/core/src/command/Help.kt new file mode 100644 index 0000000..168b69c --- /dev/null +++ b/core/src/command/Help.kt @@ -0,0 +1,39 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import kotlin.text.toUInt + +class KhsHelp : Command { + override val label = "help" + override val usage = listOf("*page") + override val description = "Lists the commands you can use" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val commands = plugin.commandGroup.commandsFor(player) + val pageSize = 4u + val pages = (commands.size.toUInt() + pageSize - 1u) / pageSize + val page = maxOf(minOf(args.firstOrNull().let { it?.toUIntOrNull() } ?: 0u, pages), 1u) + + player.message( + buildString { + appendLine( + "&b=================== &fHelp: Page ($page/$pages) &b===================" + ) + for ((label, command) in commands.chunked(pageSize.toInt()).get(page.toInt() - 1)) { + val cmd = label.substring(3) + val usage = command.usage.joinToString(" ") + val description = command.description + appendLine("&7?&f &b/hs &f$cmd &9$usage") + appendLine("&7?&f &7&o$description") + } + appendLine("&b=====================================================") + } + ) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf(parameter) + } +} diff --git a/core/src/command/Join.kt b/core/src/command/Join.kt new file mode 100644 index 0000000..88bb94c --- /dev/null +++ b/core/src/command/Join.kt @@ -0,0 +1,33 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsJoin : Command { + override val label = "join" + override val usage = listOf<String>("*map") + override val description = "Joins the game, and can set a map if the lobby is empty" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val mapName = args.firstOrNull() + val map = mapName?.let { plugin.maps.get(it) } + + runChecks(plugin, player) { + gameMapExists() + playerNotInGame() + if (mapName != null) mapSetup(map) + } + + if (plugin.game.size == 0u) plugin.game.setMap(map) + + plugin.game.join(player.uuid) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "*map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/Leave.kt b/core/src/command/Leave.kt new file mode 100644 index 0000000..752fae4 --- /dev/null +++ b/core/src/command/Leave.kt @@ -0,0 +1,22 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsLeave : Command { + override val label = "leave" + override val usage = listOf<String>() + override val description = "Leaves the game lobby" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + runChecks(plugin, player) { playerInGame() } + + plugin.game.leave(player.uuid) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf() + } +} diff --git a/core/src/command/Reload.kt b/core/src/command/Reload.kt new file mode 100644 index 0000000..eee5231 --- /dev/null +++ b/core/src/command/Reload.kt @@ -0,0 +1,33 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsReload : Command { + override val label = "reload" + override val usage = listOf<String>() + override val description = "Reload's the plugin config" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + runChecks(plugin, player) { + gameNotInProgress() + lobbyEmpty() + } + + player.message(plugin.locale.prefix.default + plugin.locale.command.reloading) + plugin + .reloadConfig() + .onSuccess { + player.message(plugin.locale.prefix.default + plugin.locale.command.reloaded) + } + .onFailure { + player.message(plugin.locale.prefix.default + plugin.locale.command.errorReloading) + } + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf() + } +} diff --git a/core/src/command/Send.kt b/core/src/command/Send.kt new file mode 100644 index 0000000..bca4467 --- /dev/null +++ b/core/src/command/Send.kt @@ -0,0 +1,30 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsSend : Command { + override val label = "send" + override val usage = listOf("map") + override val description = "Send the current lobby to another map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val map = plugin.maps.get(args.first()) + + runChecks(plugin, player) { + gameNotInProgress() + playerInGame() + mapSetup(map) + } + + plugin.game.setMap(map) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/SetExit.kt b/core/src/command/SetExit.kt new file mode 100644 index 0000000..d3feb2c --- /dev/null +++ b/core/src/command/SetExit.kt @@ -0,0 +1,25 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsSetExit : Command { + override val label = "setexit" + override val usage = listOf<String>() + override val description = "Sets the plugins's exit location" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + runChecks(plugin, player) { gameNotInProgress() } + + plugin.config.exit = player.location + plugin.saveConfig() + + player.message(plugin.locale.prefix.default + plugin.locale.map.set.exit) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf() + } +} diff --git a/core/src/command/Start.kt b/core/src/command/Start.kt new file mode 100644 index 0000000..e24c505 --- /dev/null +++ b/core/src/command/Start.kt @@ -0,0 +1,34 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsStart : Command { + override val label = "start" + override val usage = listOf("*seekers...") + override val description = + "Starts the game either with a random set of seekers or a chosen list" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + runChecks(plugin, player) { + gameMapExists() + gameNotInProgress() + playerInGame() + lobbyHasEnoughPlayers() + } + + val pool = + args + .map { plugin.shim.getPlayer(it)?.uuid } + .filterNotNull() + .filter { plugin.game.hasPlayer(it) } + + plugin.game.start(pool) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return plugin.game.players.map(Player::name) + } +} diff --git a/core/src/command/Stop.kt b/core/src/command/Stop.kt new file mode 100644 index 0000000..c0ca401 --- /dev/null +++ b/core/src/command/Stop.kt @@ -0,0 +1,27 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.game.Game +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsStop : Command { + override val label = "stop" + override val usage = listOf<String>() + override val description = "Stops the game" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + runChecks(plugin, player) { + gameMapExists() + gameInProgress() + } + + plugin.game.broadcast(plugin.locale.prefix.abort + plugin.locale.game.stop) + plugin.game.stop(Game.WinType.NONE) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf() + } +} diff --git a/core/src/command/Top.kt b/core/src/command/Top.kt new file mode 100644 index 0000000..900f52f --- /dev/null +++ b/core/src/command/Top.kt @@ -0,0 +1,49 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player + +class KhsTop : Command { + override val label = "top" + override val usage = listOf("*page") + override val description = "Shows the game leaderboard" + + private fun getColor(index: UInt): Char { + return when (index) { + 0u -> 'e' + 1u -> '7' + 2u -> '6' + else -> 'f' + } + } + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + var page = args.firstOrNull()?.toUIntOrNull() ?: 0u + page = maxOf(page, 1u) - 1u + + var pageSize = 5u + val entires = plugin.database?.getPlayers(page, pageSize) + if (entires == null || entires.isEmpty()) { + player.message(plugin.locale.prefix.default + plugin.locale.database.noInfo) + return + } + + val message = buildString { + appendLine("&f------- &lLEADERBOARD &7(Page ${page + 1u}) &f-------") + for ((i, entry) in entires.withIndex()) { + val wins = entry.hiderWins + entry.seekerWins + val idx = (pageSize * page) + i.toUInt() + val color = getColor(idx) + val name = entry.name ?: continue + appendLine("&$color${idx + 1u}. &c$wins &f$name") + } + } + + player.message(message) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf(parameter) + } +} diff --git a/core/src/command/Wins.kt b/core/src/command/Wins.kt new file mode 100644 index 0000000..02faf04 --- /dev/null +++ b/core/src/command/Wins.kt @@ -0,0 +1,41 @@ +package cat.freya.khs.command + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player + +class KhsWins : Command { + override val label = "wins" + override val usage = listOf("player") + override val description = "Shows stats for a given player" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + val data = plugin.database?.getPlayer(name) + if (data == null) { + player.message(plugin.locale.prefix.default + plugin.locale.database.noInfo) + return + } + + val message = buildString { + val wins = data.seekerWins + data.hiderWins + val games = wins + data.seekerLosses + data.hiderLosses + appendLine("&f&l" + "=".repeat(30)) + appendLine(plugin.locale.database.infoFor.with(name)) + appendLine("&bTOTAL WINS: &f$wins") + appendLine("&6HIDER WINS: &f${data.hiderWins}") + appendLine("&cSEEKER WINS: &f${data.seekerWins}") + appendLine("GAMES PLAYED: ${games}") + append("&f&l" + "=".repeat(30)) + } + + player.message(message) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return when (parameter) { + "player" -> plugin.database?.getPlayerNames(10u, typed) ?: listOf() + else -> listOf() + } + } +} diff --git a/core/src/command/map/Add.kt b/core/src/command/map/Add.kt new file mode 100644 index 0000000..8505849 --- /dev/null +++ b/core/src/command/map/Add.kt @@ -0,0 +1,35 @@ +package cat.freya.khs.command.map + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.config.MapConfig +import cat.freya.khs.game.KhsMap +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapAdd : Command { + override val label = "add" + override val usage = listOf("name", "world") + override val description = "Add a map to the plugin" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name, world) = args + runChecks(plugin, player) { + mapDoesNotExist(name) + mapNameValid(name) + worldValid(world) + } + + plugin.maps[name] = KhsMap(name, MapConfig(world), plugin) + plugin.saveConfig() + + player.message(plugin.locale.prefix.default + plugin.locale.map.created.with(name)) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "name" -> listOf("name") + "world" -> plugin.shim.worlds.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/GoTo.kt b/core/src/command/map/GoTo.kt new file mode 100644 index 0000000..20444cc --- /dev/null +++ b/core/src/command/map/GoTo.kt @@ -0,0 +1,40 @@ +package cat.freya.khs.command.map + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapGoTo : Command { + override val label = "goto" + override val usage = listOf("map", "spawn") + override val description = "Goes to a spawn location for a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name, spawn) = args + runChecks(plugin, player) { mapExists(name) } + + var map = plugin.maps.get(name) ?: return + val loc = + when (spawn) { + "spawn" -> map.gameSpawn + "lobby" -> map.lobbySpawn + "seekerlobby" -> map.seekerLobbySpawn + else -> null + } + + if (loc == null) { + player.message(plugin.locale.prefix.error + plugin.locale.map.error.locationNotSet) + return + } + + loc.teleport(player) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + "spawn" -> listOf("spawn", "lobby", "seekerlobby").filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/List.kt b/core/src/command/map/List.kt new file mode 100644 index 0000000..8bc7a81 --- /dev/null +++ b/core/src/command/map/List.kt @@ -0,0 +1,32 @@ +package cat.freya.khs.command.map + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player + +class KhsMapList : Command { + override val label = "list" + override val usage = listOf<String>() + override val description = "List maps known to the plugin" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + if (plugin.maps.isEmpty()) { + player.message(plugin.locale.prefix.default + plugin.locale.map.none) + return + } + + player.message( + buildString { + appendLine(plugin.locale.prefix.default + plugin.locale.map.list) + for ((name, map) in plugin.maps) { + append("&e- &f$name: ") + appendLine(if (map.setup) "&aSETUP" else "&cNOT SETUP") + } + } + ) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf() + } +} diff --git a/core/src/command/map/Remove.kt b/core/src/command/map/Remove.kt new file mode 100644 index 0000000..f8aab4f --- /dev/null +++ b/core/src/command/map/Remove.kt @@ -0,0 +1,32 @@ +package cat.freya.khs.command.map + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapRemove : Command { + override val label = "remove" + override val usage = listOf("map") + override val description = "Remove a map from the plugin" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + mapExists(name) + gameNotInProgress() + lobbyEmpty() + } + + plugin.maps.remove(name) + plugin.saveConfig() + + player.message(plugin.locale.prefix.default + plugin.locale.map.deleted.with(name)) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/Save.kt b/core/src/command/map/Save.kt new file mode 100644 index 0000000..a68b6cc --- /dev/null +++ b/core/src/command/map/Save.kt @@ -0,0 +1,31 @@ +package cat.freya.khs.command.map + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.game.mapSave +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapSave : Command { + override val label = "save" + override val usage = listOf("map") + override val description = "Save the map backup used for gameplay" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + mapExists(name) + gameNotInProgress() + lobbyEmpty() + } + + var map = plugin.maps.get(name) ?: return + mapSave(plugin, map) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/Status.kt b/core/src/command/map/Status.kt new file mode 100644 index 0000000..596f306 --- /dev/null +++ b/core/src/command/map/Status.kt @@ -0,0 +1,45 @@ +package cat.freya.khs.command.map + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapStatus : Command { + override val label = "status" + override val usage = listOf("map") + override val description = "Says what is needed to fully setup the map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { mapExists(name) } + + val map = plugin.maps.get(name) ?: return + + if (map.setup) { + player.message(plugin.locale.prefix.default + plugin.locale.map.setup.complete) + return + } + + player.message( + buildString { + appendLine(plugin.locale.map.setup.header) + if (map.gameSpawn == null) appendLine(plugin.locale.map.setup.game) + if (map.lobbySpawn == null) appendLine(plugin.locale.map.setup.lobby) + if (map.seekerLobbySpawn == null) appendLine(plugin.locale.map.setup.seekerLobby) + if (plugin.config.exit == null) appendLine(plugin.locale.map.setup.exit) + if (map.bounds() == null) appendLine(plugin.locale.map.setup.bounds) + if (plugin.config.mapSaveEnabled && !map.hasMapSave()) + appendLine(plugin.locale.map.setup.saveMap) + if (map.config.blockHunt.enabled && map.config.blockHunt.blocks.isEmpty()) + appendLine(plugin.locale.map.setup.blockHunt) + } + ) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/blockhunt/Debug.kt b/core/src/command/map/blockhunt/Debug.kt new file mode 100644 index 0000000..0620e3d --- /dev/null +++ b/core/src/command/map/blockhunt/Debug.kt @@ -0,0 +1,35 @@ +package cat.freya.khs.command.map.blockhunt + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.inv.createBlockHuntPicker +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapBlockHuntDebug : Command { + override val label = "debug" + override val usage = listOf("map") + override val description = "Manually open the blockhunt picker for a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + blockHuntSupported() + blockHuntEnabled(name) + } + + val map = plugin.maps.get(name) ?: return + val inv = createBlockHuntPicker(plugin, map) ?: return + player.showInventory(inv) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> + plugin.maps + .filter { it.value.config.blockHunt.enabled } + .map { it.key } + .filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/blockhunt/Enabled.kt b/core/src/command/map/blockhunt/Enabled.kt new file mode 100644 index 0000000..a2ccdb7 --- /dev/null +++ b/core/src/command/map/blockhunt/Enabled.kt @@ -0,0 +1,39 @@ +package cat.freya.khs.command.map.blockhunt + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapBlockHuntEnabled : Command { + override val label = "enabled" + override val usage = listOf("map", "bool") + override val description = "Enable/disable blockhunt on a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name, enabled) = args + runChecks(plugin, player) { + blockHuntSupported() + mapExists(name) + gameNotInProgress() + } + + val map = plugin.maps.get(name) ?: return + map.config.blockHunt.enabled = (enabled.lowercase() == "true") + map.reloadConfig() + + val msg = + if (map.config.blockHunt.enabled) plugin.locale.blockHunt.enabled + else plugin.locale.blockHunt.disabled + + plugin.saveConfig() + player.message(plugin.locale.prefix.default + msg) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + "bool" -> listOf("true", "false").filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/blockhunt/block/Add.kt b/core/src/command/map/blockhunt/block/Add.kt new file mode 100644 index 0000000..6ed17be --- /dev/null +++ b/core/src/command/map/blockhunt/block/Add.kt @@ -0,0 +1,55 @@ +package cat.freya.khs.command.map.blockhunt.block + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapBlockHuntBlockAdd : Command { + override val label = "add" + override val usage = listOf("map", "block") + override val description = "Add a block to a block hunt map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name, blockName) = args + runChecks(plugin, player) { + blockHuntSupported() + blockHuntEnabled(name) + gameNotInProgress() + lobbyEmpty() + } + + val material = plugin.shim.parseMaterial(blockName) + if (material == null) { + player.message(plugin.locale.prefix.error + plugin.locale.blockHunt.block.unknown) + return + } + + val map = plugin.maps.get(name) ?: return + if (map.config.blockHunt.blocks.contains(material)) { + player.message( + plugin.locale.prefix.error + plugin.locale.blockHunt.block.exists.with(material) + ) + return + } + + map.config.blockHunt.blocks += material + map.reloadConfig() + + plugin.saveConfig() + player.message( + plugin.locale.prefix.default + plugin.locale.blockHunt.block.added.with(material) + ) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> + plugin.maps + .filter { it.value.config.blockHunt.enabled } + .map { it.key } + .filter { it.startsWith(typed) } + "block" -> listOf(parameter) + else -> listOf() + } +} diff --git a/core/src/command/map/blockhunt/block/List.kt b/core/src/command/map/blockhunt/block/List.kt new file mode 100644 index 0000000..b7df70d --- /dev/null +++ b/core/src/command/map/blockhunt/block/List.kt @@ -0,0 +1,46 @@ +package cat.freya.khs.command.map.blockhunt.block + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapBlockHuntBlockList : Command { + override val label = "list" + override val usage = listOf("map") + override val description = "List blocks in use on a block hunt map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + blockHuntSupported() + blockHuntEnabled(name) + } + + val map = plugin.maps.get(name) ?: return + val blocks = map.config.blockHunt.blocks + if (blocks.isEmpty()) { + player.message(plugin.locale.prefix.default + plugin.locale.blockHunt.block.none) + return + } + + val message = buildString { + appendLine(plugin.locale.blockHunt.block.list) + for (block in blocks) { + appendLine("&e- &f$block") + } + } + + player.message(message) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> + plugin.maps + .filter { it.value.config.blockHunt.enabled } + .map { it.key } + .filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/blockhunt/block/Remove.kt b/core/src/command/map/blockhunt/block/Remove.kt new file mode 100644 index 0000000..3e81371 --- /dev/null +++ b/core/src/command/map/blockhunt/block/Remove.kt @@ -0,0 +1,56 @@ +package cat.freya.khs.command.map.blockhunt.block + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapBlockHuntBlockRemove : Command { + override val label = "remove" + override val usage = listOf("map", "block") + override val description = "Remove a block from a block hunt map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name, blockName) = args + runChecks(plugin, player) { + blockHuntSupported() + blockHuntEnabled(name) + gameNotInProgress() + lobbyEmpty() + } + + val material = plugin.shim.parseMaterial(blockName) + if (material == null) { + player.message(plugin.locale.prefix.error + plugin.locale.blockHunt.block.unknown) + return + } + + val map = plugin.maps.get(name) ?: return + if (!map.config.blockHunt.blocks.contains(material)) { + player.message( + plugin.locale.prefix.error + + plugin.locale.blockHunt.block.doesntExist.with(material) + ) + return + } + + map.config.blockHunt.blocks -= material + map.reloadConfig() + + plugin.saveConfig() + player.message( + plugin.locale.prefix.default + plugin.locale.blockHunt.block.removed.with(material) + ) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> + plugin.maps + .filter { it.value.config.blockHunt.enabled } + .map { it.key } + .filter { it.startsWith(typed) } + "block" -> listOf(parameter) + else -> listOf() + } +} diff --git a/core/src/command/map/set/Border.kt b/core/src/command/map/set/Border.kt new file mode 100644 index 0000000..75a3f27 --- /dev/null +++ b/core/src/command/map/set/Border.kt @@ -0,0 +1,64 @@ +package cat.freya.khs.command.map.set + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapSetBorder : Command { + override val label = "border" + override val usage = listOf("map", "size", "delay", "move") + override val description = "Enable the world border for a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name, sizeS, delayS, moveS) = args + runChecks(plugin, player) { + mapExists(name) + inMapWorld(name) + gameNotInProgress() + } + + val size = sizeS.toULong() + val delay = delayS.toULong() + val move = moveS.toULong() + + if (size < 100u) { + player.message(plugin.locale.prefix.error + plugin.locale.worldBorder.minSize) + return + } + + if (move < 1u) { + player.message(plugin.locale.prefix.error + plugin.locale.worldBorder.minChange) + return + } + + var map = plugin.maps.get(name) ?: return + val config = map.config.worldBorder + config.enabled = true + config.pos = player.location.position + config.size = size + config.delay = delay + config.move = move + + runChecks(plugin, player) { + // note this is not error, only warn + spawnsInRange(map) + } + + map.reloadConfig() + + plugin.saveConfig() + player.message( + plugin.locale.prefix.default + plugin.locale.worldBorder.enable.with(size, delay, move) + ) + + val loc = player.location.position + map.world?.border?.move(loc.x, loc.z, size, 0UL) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf(parameter) + } +} diff --git a/core/src/command/map/set/Bounds.kt b/core/src/command/map/set/Bounds.kt new file mode 100644 index 0000000..7c13802 --- /dev/null +++ b/core/src/command/map/set/Bounds.kt @@ -0,0 +1,57 @@ +package cat.freya.khs.command.map.set + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.config.BoundConfig +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapSetBounds : Command { + override val label = "bounds" + override val usage = listOf("map") + override val description = "Sets the map bounds for a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + mapExists(name) + inMapWorld(name) + gameNotInProgress() + } + + var map = plugin.maps.get(name) ?: return + val config = map.config.bounds + + val pos = player.location.position + val num: Int + + if (config.min == null || config.max != null) { + config.min = BoundConfig(pos.x, pos.z) + config.max == null + num = 1 + } else { + val minX = minOf(config.min?.x ?: 0.0, pos.x) + val minZ = minOf(config.min?.z ?: 0.0, pos.z) + val maxX = maxOf(config.min?.x ?: 0.0, pos.x) + val maxZ = maxOf(config.min?.z ?: 0.0, pos.z) + config.min = BoundConfig(minX, minZ) + config.max = BoundConfig(maxX, maxZ) + num = 2 + } + + runChecks(plugin, player) { + // note this is not error, only warn + spawnsInRange(map) + } + + map.reloadConfig() + plugin.saveConfig() + player.message(plugin.locale.prefix.default + plugin.locale.map.set.bounds.with(num)) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/set/Lobby.kt b/core/src/command/map/set/Lobby.kt new file mode 100644 index 0000000..a90259a --- /dev/null +++ b/core/src/command/map/set/Lobby.kt @@ -0,0 +1,37 @@ +package cat.freya.khs.command.map.set + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapSetLobby : Command { + override val label = "lobby" + override val usage = listOf("map") + override val description = "Sets the lobby spawn location for a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + mapExists(name) + inMapWorld(name) + gameNotInProgress() + } + + var map = plugin.maps.get(name) ?: return + val pos = player.location.position + + runChecks(plugin, player) { spawnInRange(map, pos) } + + map.config.spawns.lobby = pos + map.reloadConfig() + plugin.saveConfig() + player.message(plugin.locale.prefix.default + plugin.locale.map.set.lobby) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/set/SeekerLobby.kt b/core/src/command/map/set/SeekerLobby.kt new file mode 100644 index 0000000..71122cb --- /dev/null +++ b/core/src/command/map/set/SeekerLobby.kt @@ -0,0 +1,38 @@ +package cat.freya.khs.command.map.set + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapSetSeekerLobby : Command { + override val label = "seekerlobby" + override val usage = listOf("map") + override val description = "Sets the seeker lobby spawn location for a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + mapExists(name) + inMapWorld(name) + gameNotInProgress() + } + + var map = plugin.maps.get(name) ?: return + val pos = player.location.position + + runChecks(plugin, player) { spawnInRange(map, pos) } + + map.config.spawns.seeker = pos + map.reloadConfig() + + plugin.saveConfig() + player.message(plugin.locale.prefix.default + plugin.locale.map.set.seekerSpawn) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/set/Spawn.kt b/core/src/command/map/set/Spawn.kt new file mode 100644 index 0000000..4eff730 --- /dev/null +++ b/core/src/command/map/set/Spawn.kt @@ -0,0 +1,38 @@ +package cat.freya.khs.command.map.set + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapSetSpawn : Command { + override val label = "spawn" + override val usage = listOf("map") + override val description = "Sets the game spawn location for a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + mapExists(name) + inMapWorld(name) + gameNotInProgress() + } + + var map = plugin.maps.get(name) ?: return + val pos = player.location.position + + runChecks(plugin, player) { spawnInRange(map, pos) } + + map.config.spawns.game = pos.toLegacy() + map.reloadConfig() + + plugin.saveConfig() + player.message(plugin.locale.prefix.default + plugin.locale.map.set.gameSpawn) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/map/unset/Border.kt b/core/src/command/map/unset/Border.kt new file mode 100644 index 0000000..87e7b85 --- /dev/null +++ b/core/src/command/map/unset/Border.kt @@ -0,0 +1,39 @@ +package cat.freya.khs.command.map.unset + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsMapUnsetBorder : Command { + override val label = "border" + override val usage = listOf("map") + override val description = "Disable the world border for a map" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + mapExists(name) + gameNotInProgress() + } + + var map = plugin.maps.get(name) ?: return + val config = map.config.worldBorder + config.enabled = false + config.pos = null + config.size = null + config.delay = null + config.move = null + + plugin.saveConfig() + player.message(plugin.locale.prefix.default + plugin.locale.worldBorder.disable) + + map.world?.border?.move(0.0, 0.0, 30_000_000UL, 0UL) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> = + when (parameter) { + "map" -> plugin.maps.keys.filter { it.startsWith(typed) } + else -> listOf() + } +} diff --git a/core/src/command/util/Command.kt b/core/src/command/util/Command.kt new file mode 100644 index 0000000..734305a --- /dev/null +++ b/core/src/command/util/Command.kt @@ -0,0 +1,17 @@ +package cat.freya.khs.command.util + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +interface CommandPart { + val label: String +} + +interface Command : CommandPart { + val usage: List<String> + val description: String + + fun execute(plugin: Khs, player: Player, args: List<String>) + + fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> +} diff --git a/core/src/command/util/CommandGroup.kt b/core/src/command/util/CommandGroup.kt new file mode 100644 index 0000000..72659d5 --- /dev/null +++ b/core/src/command/util/CommandGroup.kt @@ -0,0 +1,134 @@ +package cat.freya.khs.command.util + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +private data class CommandData(val command: Command, val permission: String, val args: List<String>) + +class CommandGroup(val plugin: Khs, override val label: String, vararg commands: CommandPart) : + CommandPart { + // set of commands to run in this group + private final val REGISTRY: Map<String, CommandPart> = + commands.associate { it.label.lowercase() to it } + + private fun getCommand(args: List<String>, permission: String): CommandData? { + val invoke = args.firstOrNull()?.lowercase() ?: return null + val command = REGISTRY.get(invoke) ?: return null + + return when (command) { + is Command -> CommandData(command, "$permission.$invoke", args.drop(1)) + is CommandGroup -> command.getCommand(args.drop(1), "$permission.$invoke") + else -> null + } + } + + private fun messageAbout(player: Player) { + val version = plugin.shim.pluginVersion + player.message( + "&b&lKenshin's Hide and Seek &7(&f$version&7)\n" + + "&7Author: &f[KenshinEto]\n" + + "&7Help Command: &b/hs &fhelp" + ) + } + + fun handleCommand(player: Player, args: List<String>) { + val data = getCommand(args, label) ?: return messageAbout(player) + + if (plugin.saving) { + player.message(plugin.locale.prefix.error + plugin.locale.command.notAllowedTemp) + return + } + + if (plugin.config.permissionsRequired && !player.hasPermission(data.permission)) { + player.message(plugin.locale.prefix.error + plugin.locale.command.notAllowed) + return + } + + val paramCount = data.command.usage.filter { it.firstOrNull() != '*' }.count() + if (data.args.size < paramCount) { + player.message(plugin.locale.prefix.error + plugin.locale.command.notEnoughArguments) + return + } + + runCatching { data.command.execute(plugin, player, data.args) } + .onFailure { + player.message( + plugin.locale.prefix.error + (it.message ?: plugin.locale.command.unknownError) + ) + + if (plugin.config.debug) { + plugin.shim.logger.warning("=== KHS BEGIN DEBUG TRACE ===") + plugin.shim.logger.warning(it.stackTraceToString()) + plugin.shim.logger.warning("=== KHS END DEBUG TRACE ===") + } + } + } + + private fun handleTabComplete( + player: Player, + args: List<String>, + permission: String, + ): List<String> { + val invoke = args.firstOrNull()?.lowercase() ?: return listOf() + val command = REGISTRY.get(invoke) + return when { + command is Command -> { + if ( + plugin.config.permissionsRequired && + !player.hasPermission("$permission.$invoke") + ) + return listOf() + + var index = maxOf(args.size - 1, 1) + val typed = args.getOrNull(index) ?: return listOf() + + // handle last argument of usage being a varadic (...) + if ( + index >= command.usage.size && + command.usage.lastOrNull()?.endsWith("...") == true + ) + index = command.usage.size + + val parameter = command.usage.getOrNull(index - 1) ?: return listOf() + + command.autoComplete(plugin, parameter, typed) + } + command is CommandGroup -> + command.handleTabComplete(player, args.drop(1), "$permission.$invoke") + args.size == 1 -> REGISTRY.keys.filter { it.startsWith(invoke) } + else -> listOf() + } + } + + fun handleTabComplete(player: Player, args: List<String>): List<String> { + return handleTabComplete(player, args, label) + } + + private fun commandsFor( + player: Player, + label: String, + permission: String, + res: MutableList<Pair<String, Command>>, + ) { + for ((invoke, command) in REGISTRY) { + when (command) { + is Command -> { + if ( + plugin.config.permissionsRequired && + !player.hasPermission("$permission.$invoke") + ) + continue + res.add("$label $invoke" to command) + } + is CommandGroup -> + command.commandsFor(player, "$label $invoke", "$permission.$invoke", res) + } + } + } + + fun commandsFor(player: Player): List<Pair<String, Command>> { + val commands = mutableListOf<Pair<String, Command>>() + commandsFor(player, label, label, commands) + return commands + } +} diff --git a/core/src/command/world/Create.kt b/core/src/command/world/Create.kt new file mode 100644 index 0000000..2deece7 --- /dev/null +++ b/core/src/command/world/Create.kt @@ -0,0 +1,40 @@ +package cat.freya.khs.command.world + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks +import cat.freya.khs.world.World + +class KhsWorldCreate : Command { + override val label = "create" + override val usage = listOf("name", "type") + override val description = "Create a new world" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name, typeStr) = args + runChecks(plugin, player) { worldDoesNotExist(name) } + + val type = + World.Type.values().find { it.name.lowercase() == typeStr.lowercase() } + ?: World.Type.NORMAL + + val world = plugin.shim.createWorld(name, type) + if (world == null) { + player.message(plugin.locale.prefix.error + plugin.locale.world.addedFailed.with(name)) + return + } + + player.teleport(world.spawn.withWorld(name)) + player.message(plugin.locale.prefix.default + plugin.locale.world.added.with(name)) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return when (parameter) { + "name" -> listOf(parameter) + "type" -> + World.Type.values().map { it.name.lowercase() }.filter { it.startsWith(typed) } + else -> listOf() + } + } +} diff --git a/core/src/command/world/Delete.kt b/core/src/command/world/Delete.kt new file mode 100644 index 0000000..64710a6 --- /dev/null +++ b/core/src/command/world/Delete.kt @@ -0,0 +1,50 @@ +package cat.freya.khs.command.world + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks +import java.io.File + +class KhsWorldDelete : Command { + override val label = "delete" + override val usage = listOf("name") + override val description = "Delete an existing world" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { + worldExists(name) + worldNotInUse(name) + } + + val loader = plugin.shim.getWorldLoader(name) + + // sanity check + // for the love of god, make sure were rm -fr'ing a world, not like + // some ones home dir ;-; + val lock = File(loader.dir, "session.lock") + val data = File(loader.dir, "level.dat") + if (!lock.exists() || !data.exists()) { + player.message(plugin.locale.prefix.error + plugin.locale.world.doesntExist.with(name)) + return + } + + loader.unload() + if (!loader.dir.deleteRecursively()) { + player.message( + plugin.locale.prefix.error + plugin.locale.world.removedFailed.with(name) + ) + return + } + + player.message(plugin.locale.prefix.default + plugin.locale.world.removed.with(name)) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return when (parameter) { + "name" -> plugin.shim.worlds.filter { it.startsWith(typed) } + else -> listOf() + } + } +} diff --git a/core/src/command/world/List.kt b/core/src/command/world/List.kt new file mode 100644 index 0000000..af48f03 --- /dev/null +++ b/core/src/command/world/List.kt @@ -0,0 +1,42 @@ +package cat.freya.khs.command.world + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.world.World + +class KhsWorldList : Command { + override val label = "list" + override val usage = listOf<String>() + override val description = "Teleport to a world's spawn" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val worlds = plugin.shim.worlds + if (worlds.isEmpty()) { + // uhhh, we have to be in a world to call this 0_0 + player.message(plugin.locale.prefix.error + plugin.locale.world.none) + return + } + + val message = buildString { + appendLine(plugin.locale.world.list) + for (worldName in worlds) { + val world = plugin.shim.getWorld(worldName) + val status = + when (world?.type) { + World.Type.NORMAL -> "&aNORMAL" + World.Type.FLAT -> "&aFLAT" + World.Type.NETHER -> "&cNETHER" + World.Type.END -> "&eEND" + else -> "&7NOT LOADED" + } + appendLine("&e- &f$worldName: $status") + } + } + player.message(message) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return listOf() + } +} diff --git a/core/src/command/world/Tp.kt b/core/src/command/world/Tp.kt new file mode 100644 index 0000000..c103a0c --- /dev/null +++ b/core/src/command/world/Tp.kt @@ -0,0 +1,36 @@ +package cat.freya.khs.command.world + +import cat.freya.khs.Khs +import cat.freya.khs.command.util.Command +import cat.freya.khs.player.Player +import cat.freya.khs.runChecks + +class KhsWorldTp : Command { + override val label = "tp" + override val usage = listOf("name") + override val description = "Teleport to a world's spawn" + + override fun execute(plugin: Khs, player: Player, args: List<String>) { + val (name) = args + runChecks(plugin, player) { worldExists(name) } + + val loader = plugin.shim.getWorldLoader(name) + loader.load() + + val world = plugin.shim.getWorld(name) + if (world == null) { + player.message(plugin.locale.prefix.error + plugin.locale.world.loadFailed.with(name)) + return + } + + val spawn = world.spawn.withWorld(name) + player.teleport(spawn) + } + + override fun autoComplete(plugin: Khs, parameter: String, typed: String): List<String> { + return when (parameter) { + "name" -> plugin.shim.worlds.filter { it.startsWith(typed) } + else -> listOf() + } + } +} diff --git a/core/src/config/Board.kt b/core/src/config/Board.kt new file mode 100644 index 0000000..434bfaa --- /dev/null +++ b/core/src/config/Board.kt @@ -0,0 +1,94 @@ +package cat.freya.khs.config + +data class LobbyBoardConfig( + var title: String = "&eHIDE AND SEEK", + var content: List<String> = + listOf( + "{COUNTDOWN}", + "", + "Players: {COUNT}", + "", + "&cSEEKER % &f{SEEKER%}", + "&6HIDER % &f{HIDER%}", + "", + "Map: {MAP}", + ), +) + +data class GameBoardConfig( + var title: String = "&eHIDE AND SEEK", + var content: List<String> = + listOf( + "Map: {MAP}", + "Team: {TEAM}", + "", + "Time Left: &a{TIME}", + "", + "Taunt: &e{TAUNT}", + "Glow: {GLOW}", + "Border: &b{BORDER}", + "", + "&cSEEKERS: &f{#SEEKER}", + "&6HIDERS: &f{#HIDER}", + ), +) + +data class CountdownBoardConfig( + var waiting: String = "Waiting for players...", + @Comment("{1} - time in seconds till game start") + var startingIn: LocaleString1 = LocaleString1("Starting in: &a{1}s"), + @Comment("{1} - how many minutes till game end") + @Comment("{2} - how many seconds till game end") + var timer: LocaleString2 = LocaleString2("{1}m{2}s"), +) + +data class TauntBoardConfig( + @Comment("{1} - number of minutes till taunt event") + @Comment("{2} - number of seconds till taunt event") + var timer: LocaleString2 = LocaleString2("{1}m{2}s"), + var active: String = "Active", +) + +data class GlowBoardConfig(var active: String = "&aActive", var disabled: String = "&cDisabled") + +data class BorderBoardConfig( + @Comment("{1} - number of minutes till border event") + @Comment("{2} - number of seconds till border event") + var timer: LocaleString2 = LocaleString2("{1}m{2}s"), + var shrinking: String = "Shrinking", +) + +data class KhsBoardConfig( + @Section("Lobby") + @Comment("Change what is displayed on the scoreboard/leaderboard") + @Comment("while in the lobby") + @Comment("") + @Comment(" {COUNTDOWN} - Displays the time left until the game starts") + @Comment(" {COUNT} - The amount of players in the lobby") + @Comment(" {SEEKER%} - % chance that you will be a seeker") + @Comment(" {HIDER%} - % chance that you will be a hider") + @Comment(" {MAP} - The name of the current map") + var lobby: LobbyBoardConfig = LobbyBoardConfig(), + @Section("Game") + @Comment("Change what is displayed on the scoreboard/leaderboard") + @Comment("while playing the game") + @Comment("") + @Comment(" {TIME} - The time left in the game (MM:SS)") + @Comment(" {TEAM} - The team you are on") + @Comment(" {BORDER} - The current status of the world border event") + @Comment(" {TAUNT} - The current status of the taunt event") + @Comment(" {GLOW} - The current status of the glow powerup") + @Comment(" {#SEEKER} - The number of seekers in the game right now") + @Comment(" {#HIDER} - The number of hiders in the game right now") + @Comment(" {MAP} - The name of the current map") + var game: GameBoardConfig = GameBoardConfig(), + @Section("Templates") + @Comment("Locale strings for the {COUNTDOWN} display") + var countdown: CountdownBoardConfig = CountdownBoardConfig(), + @Comment("Locale strings for the {TAUNT} placeholder") + var taunt: TauntBoardConfig = TauntBoardConfig(), + @Comment("Locale strings for the {GLOW} placeholder") + var glow: GlowBoardConfig = GlowBoardConfig(), + @Comment("Locale strings for the {BORDER} placeholder") + var border: BorderBoardConfig = BorderBoardConfig(), +) diff --git a/core/src/config/Config.kt b/core/src/config/Config.kt new file mode 100644 index 0000000..5cb2406 --- /dev/null +++ b/core/src/config/Config.kt @@ -0,0 +1,326 @@ +package cat.freya.khs.config + +import cat.freya.khs.world.Location +import kotlin.UInt +import kotlin.annotation.AnnotationTarget + +@Target(AnnotationTarget.PROPERTY) annotation class Section(val text: String) + +@Repeatable @Target(AnnotationTarget.PROPERTY) annotation class Comment(val text: String) + +@Target(AnnotationTarget.PROPERTY) annotation class Omittable() + +@Target(AnnotationTarget.PROPERTY) annotation class KhsDeprecated(val since: String) + +enum class ConfigCountdownDisplay { + CHAT, + ACTIONBAR, + TITLE, +} + +enum class ConfigScoringMode { + ALL_HIDERS_FOUND, + LAST_HIDER_WINS, +} + +enum class ConfigLeaveType { + EXIT, + PROXY, +} + +data class DelayedRespawnConfig( + var enabled: Boolean = true, + @Comment("How long do players have to wait in seconds before respawning") var delay: UInt = 5u, +) + +enum class DatabaseType { + SQLITE, + MYSQL, + POSTGRES, +} + +data class DatabaseConfig( + @Comment("The type of database to store user data in") + @Comment("SQLITE - local file in plugin directory, fine for most small servers") + @Comment("MYSQL - remote sql server running mysql") + @Comment("POSTGRES - remote sql server running postgresql") + var type: DatabaseType = DatabaseType.SQLITE, + @Comment("The following options are only required for mysql or postgres") + var host: String = "localhost", + var port: ULong? = null, + var username: String = "postgres", + var password: String = "postgres", + var database: String = "postgres", +) + +data class ItemConfig( + @Omittable var name: String? = null, + var material: String = "DIRT", + var lore: List<String> = emptyList(), + var enchantments: Map<String, UInt> = emptyMap(), + @Omittable var unbreakable: Boolean? = null, + @Omittable var modelData: UInt? = null, + @Omittable var owner: String? = null, +) + +data class EffectConfig( + var type: String = "SPEED", + var duration: UInt = 60u, + var amplifier: UInt = 1u, + var ambient: Boolean = true, + var particles: Boolean = true, +) + +data class TauntConfig( + var enabled: Boolean = true, + @Comment("The delay in seconds between taunts, minimum is 60 seconds") var delay: ULong = 360u, + @Comment("If to disable the taunt when there is only a single hider left") + var disableForLastHider: Boolean = false, + @Comment("Show the countdown till next taunt for everyone") var showCountdown: Boolean = true, +) + +data class GlowConfig( + var enabled: Boolean = true, + @Comment("How long in seconds does the powerup last") var time: ULong = 30u, + @Comment("If multiple powerup uses can stack the time left") var stackable: Boolean = true, + @Comment("The config for the powerup item") + var item: ItemConfig = + ItemConfig( + "Glow Powerup", // Name + "SNOWBALL", // Material + listOf( + "Throw to make all seekers glow", + "Last 30s, all hiders can see it", + "Time stacks on multi use", + ), + ), // Lore +) + +data class LobbyConfig( + @Comment("Time in seconds that the lobby waits until game starts. Set to 0 to disable") + var countdown: ULong = 60u, + @Comment("Player threshold to speed up the countdown. Set to 0 to disable") + var changeCountdown: UInt = 5u, + @Comment("Minimum amount of players required to start the countdown") var min: UInt = 3u, + @Comment("Maximum amount of players allowed in a lobby") var max: UInt = 10u, + @Comment("Item for players to use to leave the lobby") + var leaveItem: ItemConfig = + ItemConfig( + "&c Leave Lobby", // Name + "BED", // Material + listOf("Go back to server hub"), + ), // Lore + @Comment("Item for admins to use to force start the game") + var startItem: ItemConfig = + ItemConfig( + "&bStart Game", // Name + "CLOCK", + ), // Material +) + +data class SpectatorItemsConfig( + /// Item for spectators to toggle flight + var flight: ItemConfig = + ItemConfig( + "&bToggle Flight", // Name + "FEATHER", // Material + listOf("Turns flying on and off"), + ), // Lore + + /// Item for spectators to teleport to other players + var teleport: ItemConfig = + ItemConfig( + "&bTeleport to Others", // Name + "COMPASS", // Material + listOf("Allows you to teleport to all other players in game"), + ), // Lore +) + +data class SeekerPingDistancesConfig( + var level1: UInt = 30u, + var level2: UInt = 20u, + var level3: UInt = 10u, +) + +data class SeekerPingConfigSounds( + @Comment("The noise for the heartbeat") + var heartbeatNoise: String = "BLOCK_NOTE_BLOCK_BASEDRUM", + @Comment("The noise for the ringing") var ringingNoise: String = "BLOCK_NOTE_BLOCK_PLING", + var leadingVolume: Double = 0.5, + var volume: Double = 0.3, + var pitch: Double = 1.0, +) + +data class SeekerPingConfig( + var enabled: Boolean = true, + @Comment("The distances for the volume to change") + var distances: SeekerPingDistancesConfig = SeekerPingDistancesConfig(), + @Comment("The sounds that players will hear") + var sounds: SeekerPingConfigSounds = SeekerPingConfigSounds(), +) + +data class KhsConfig( + /* General */ + + @Section("General") + @Comment("Allow players to drop their items in game") + var dropItems: Boolean = false, + @Comment("When the game is starting, the plugin will state there is x seconds left to hide.") + @Comment( + "You change where countdown messages are to be displayed: in the chat, action bar, or a title." + ) + @Comment("Below you can set CHAT, ACTIONBAR, or TITLE. Any invarid option will revert to CHAT.") + var countdownDisplay: ConfigCountdownDisplay = ConfigCountdownDisplay.CHAT, + @Comment( + "Allow Hiders to see their own teams nametags as well as seekers. Seekers can never see nametags regardless" + ) + var nametagsVisible: Boolean = false, + @Comment( + "Require bukkit permissions though a permission plugin to run commands, or require op, recommended on most servers" + ) + var permissionsRequired: Boolean = true, + @Comment("Minimum amount of players to start the game. Cannot go lower than 2.") + var minPlayers: UInt = 2u, + @Comment("Amount of initial seekers when the game starts, minimum of 1") + var startingSeekerCount: UInt = 1u, + @Comment( + "By default, when a HIDER dies they will join the SEEKER team. If enabled they will instead become a SPECTATOR." + ) + var respawnAsSpectator: Boolean = false, + @Comment("Along with a char message, display a title describing the game over") + var gameOverTitle: Boolean = true, + @Comment("Configure items given to spectators") + var spectatorItems: SpectatorItemsConfig = SpectatorItemsConfig(), + @Comment("Configure the sounds that plays when a seeker is near") + var seekerPing: SeekerPingConfig = SeekerPingConfig(), + @Comment("For developers") var debug: Boolean = false, + + /* Timing */ + + @Section("Timing") + @Comment("How long in seconds will the game last, set to 0 to make game length infinite") + var gameLength: ULong = 1200u, + @Comment("How long in seconds will the initial hiding period last, minimum is 10 seconds") + var hidingLength: ULong = 30u, + @Comment( + "The amount of seconds the game will wait until the players are teleported to the lobby after a game over" + ) + var endGameDelay: ULong = 5u, + @Comment( + "If you die in game, you will have to wait [delay] seconds until you respawn, so that if you were a seeker," + ) + @Comment( + "you cannot instantly go to where the Hider that killed you was. Or if you were a Hider and dies," + ) + @Comment("you can't instantly go to where you know other Hiders are. This can be disabled.") + var delayedRespawn: DelayedRespawnConfig = DelayedRespawnConfig(), + + /* Database */ + + @Section("Database") var database: DatabaseConfig = DatabaseConfig(), + + /* Scoring */ + + @Section("Scoring") + @Comment("The scoring mode decides the criteria for when the game has finished and who wins.") + @Comment( + "ALL_HIDERS_FOUND - The game will go until no hiders are left. If the timer runs out all hiders left will win." + ) + @Comment( + "LAST_HIDER_WINS - The game will go until there is only one hider left. If the timer runs out, all hiders left win. If there is only one hider left, all initial seekers win along with the last hider." + ) + var scoringMode: ConfigScoringMode = ConfigScoringMode.ALL_HIDERS_FOUND, + @Comment( + "When enabled, if the last hider or seeker quits the game, a wine type of NONE is given, which doesn't mark anyone as winning." + ) + @Comment( + "This can be used as a way to prevent players from quitting in a loop to get someone else points." + ) + var dontRewardQuit: Boolean = true, + + /* PVP */ + + @Section("PVP") + @Comment( + "This plugin by default functions as not tag to catch Hiders, but to pvp. All players are given weapons," + ) + @Comment( + "and seekers slightly better weapons (this can be changed in items.yml). If you want, you can disable this" + ) + @Comment( + "entire pvp functionality, and make Hiders get found on a single hit. Hiders would also not be able to fight" + ) + @Comment("back against Seekers if disabled.") + var pvp: Boolean = true, + @Comment("Allow players to regen health") var regenHealth: Boolean = false, + @Comment( + "If pvp is disabled, Hiders and Seekers can no longer take damage from natural causes unless this option is enabled." + ) + @Comment("Such natural causes could be fall damage or projectiles.") + var allowNaturalCauses: Boolean = false, + + /* Lobby */ + + @Section("Lobby") + @Comment("Players that join the server will automatically be added into a game lobby") + var autoJoin: Boolean = false, + @Comment( + "When players join the world contaning the lobby, teleport them to the designated exit position so that they don't spawn in the lobby while not in the queue." + ) + @Comment("This setting is ignored when autoJoin is set to true.") + var teleportStraysToExit: Boolean = false, + @Comment("How to handle players leaving a game lobby.") + @Comment("EXIT - Teleport the player to the designated exit location") + @Comment("PROXY - Teleport the player to another server in a bungeecord/velocity network") + var leaveType: ConfigLeaveType = ConfigLeaveType.EXIT, + @Comment("The server to teleport to when leaveType is set to PROXY") + var leaveServer: String = "lobby", + @Comment("If to leave the game lobby after a game ends") var leaveOnEnd: Boolean = false, + @Comment("Configure the \"waiting for players\" per map lobby") + var lobby: LobbyConfig = LobbyConfig(), + @Comment("Restore the players previously cleared inventory after leaving the game lobby") + var saveInventory: Boolean = false, + + /* Events */ + + @Section("Events") @Comment("Taunt event") var taunt: TauntConfig = TauntConfig(), + + /* Powerups */ + + @Section("Powerups") @Comment("Glow powerup") var glow: GlowConfig = GlowConfig(), + @Comment( + "Instead of having a glow powerup, always make seeker position's known the the hider at all times." + ) + var alwaysGlow: Boolean = false, + + /* Protections */ + + @Section("Protections") + @Comment( + "By default, the plugin forces you to use a map save to protect from changes to a map thought a game play though. It copies your" + ) + @Comment( + "hide-and-seek world to a separate world, and loads the game there to contain the game in an isolated and backed up map. This allows you to" + ) + @Comment( + "not worry about your hide-and-seek map from changing, as all changes are made are in a separate world file that doesn't get saved. Once the game" + ) + @Comment( + "ends, it unloads the map and doesn't save. Then reloads the duplicate to the original state, rolling back the map for the next game." + ) + @Comment( + "It is highly recommended that you keep this set to true unless you have other means of protecting your hide-and-seek map." + ) + var mapSaveEnabled: Boolean = true, + @Comment("Block these commands for players in a game. Good for blocking communication") + var blockedCommands: List<String> = listOf("msg", "reply", "me"), + @Comment("Dont allow players to interact with these blocks") + var blockedInteracts: List<String> = + listOf("FURNACE", "CRAFTING_TABLE", "ANVIL", "CHEST", "BARREL"), + + /* Auto Generated */ + + @Section("Auto Generated") + @Comment("Location where players are teleported to when they run (/hs leave).") + var exit: Location? = null, +) diff --git a/core/src/config/Items.kt b/core/src/config/Items.kt new file mode 100644 index 0000000..31feb95 --- /dev/null +++ b/core/src/config/Items.kt @@ -0,0 +1,115 @@ +package cat.freya.khs.config + +data class KhsItemsConfig( + @Section("Hider Items") + @Comment("Items that hiders are given") + var hiderItems: List<ItemConfig> = + listOf( + // Stone sword + ItemConfig( + "Hider Sword", // Name + "STONE_SWORD", // Material + listOf("This is the hider sword"), // Lore + mapOf("sharpness" to 2u), // Enchantments + true, + ), // Unbreakable + // Regen potion + ItemConfig( + null, // Name + "SPLASH_POTION:REGEN", + ), // Material + // Heal potion + ItemConfig( + null, // Name + "POTION:INSTANT_HEAL", + ), + ), // Material + var hiderHelmet: ItemConfig? = null, + var hiderChestplate: ItemConfig? = null, + var hiderLeggings: ItemConfig? = null, + var hiderBoots: ItemConfig? = null, + @Section("Seeker Items") + @Comment("Items that seekers are given") + var seekerItems: List<ItemConfig> = + listOf( + // Diamond sword + ItemConfig( + "Seeker Sword", // Name + "DIAMOND_SWORD", // Material + listOf("this is the seeker sword"), // Lore + mapOf("sharpness" to 1u), // Enchantments + true, + ), // Unbreakable + // Wacky stick + ItemConfig( + "Wacky Stick", // Name + "STICK", // Material + listOf("It will launch people very far", "Use wisely!"), // Lore + mapOf("knockback" to 3u), + ), + ), // Enchantments + + // Armor provided to seekers + var seekerHelmet: ItemConfig? = ItemConfig(null, "LEATHER_HELMET"), + var seekerChestplate: ItemConfig? = ItemConfig(null, "LEATHER_CHESTPLATE"), + var seekerLeggings: ItemConfig? = ItemConfig(null, "LEATHER_LEGGINGS"), + var seekerBoots: ItemConfig? = + ItemConfig( + null, // Name + "LEATHER_BOOTS", // Material + emptyList(), // Lore + mapOf("feather_falling" to 4u), + ), // Enchantments + @Section("Hider Effects") + @Comment("Effects hiders are given at the start of the round") + var hiderEffects: List<EffectConfig> = + listOf( + EffectConfig( + "WATER_BREATHING", // Type + 1000000u, // Duration + 1u, // Amplifier + false, // Ambient + false, + ), // Particles + EffectConfig( + "DOLPHINS_GRACE", // Type + 1000000u, // Duration + 1u, // Amplifier + false, // Ambient + false, + ), + ), // Particles + @Section("Seeker Effects") + @Comment("Effects seekers given at the start of the round and when they respawn") + var seekerEffects: List<EffectConfig> = + listOf( + EffectConfig( + "SPEED", // Type + 1000000u, // Duration + 2u, // Amplifier + false, // Ambient + false, + ), // Particles + EffectConfig( + "JUMP", // Type + 1000000u, // Duration + 1u, // Amplifier + false, // Ambient + false, + ), // Particles + EffectConfig( + "WATER_BREATHING", // Type + 1000000u, // Duration + 10u, // Amplifier + false, // Ambient + false, + ), // Particles + EffectConfig( + "DOLPHINS_GRACE", // Type + 1000000u, // Duration + 1u, // Amplifier + false, // Ambient + false, + ), + ), // Particles +) diff --git a/core/src/config/Locale.kt b/core/src/config/Locale.kt new file mode 100644 index 0000000..7821fbe --- /dev/null +++ b/core/src/config/Locale.kt @@ -0,0 +1,321 @@ +package cat.freya.khs.config + +@JvmInline +value class LocaleString1(val inner: String) { + fun with(arg1: Any): String { + return this.inner.replace("{1}", arg1.toString()) + } + + override fun toString(): String = "[LocaleString1]" +} + +@JvmInline +value class LocaleString2(val inner: String) { + fun with(arg1: Any, arg2: Any): String { + return this.inner.replace("{1}", arg1.toString()).replace("{2}", arg2.toString()) + } + + override fun toString(): String = "[LocaleString2]" +} + +@JvmInline +value class LocaleString3(val inner: String) { + fun with(arg1: Any, arg2: Any, arg3: Any): String { + return this.inner + .replace("{1}", arg1.toString()) + .replace("{2}", arg2.toString()) + .replace("{3}", arg3.toString()) + } + + override fun toString(): String = "[LocaleString3]" +} + +data class LocalePrefixConfig( + var default: String = "&9Hide and Seek > &f", + var warning: String = "&eWarning > &f", + var error: String = "&cError > &f", + var abort: String = "&cAbort > &f", + var taunt: String = "&eTaunt > &f", + var border: String = "&cWorld Border > &f", + var gameOver: String = "&aGame Over > &f", +) + +data class LocalePlaceholderConfig( + @Comment("Displayed string if the requested placeholder is invalid") + var invalid: String = "{Error}", + @Comment("Displayed string if the requested placeholder is empty") + var noData: String = "{No Data}", +) + +data class LocaleCommandConfig( + var playerOnly: String = "This command can only be run by a player", + var notAllowed: String = "You are not allowed to run this command", + var notAllowedTemp: String = "You are not allowed to run this command right now", + var unknownError: String = "An unknown error has occoured", + @Comment("{1} - position of invalid argument") + var invalidArgument: LocaleString1 = LocaleString1("Invalid argument: {1}"), + var notEnoughArguments: String = "This command requires more arguments to run", + @Comment("{1} - the invalid integer") + var invalidInteger: LocaleString1 = LocaleString1("Invalid integer: {1}"), + @Comment("{1} - the invalid player name") + var invalidPlayer: LocaleString1 = LocaleString1("Invalid player: {1}"), + var reloading: String = "Reloading the config...", + var reloaded: String = "Reloaded the config", + var errorReloading: String = "Error reloading config, please check the server logs!", +) + +data class LocaleGamePlayerConfig( + @Comment("{1} - name of the player who died") + var death: LocaleString1 = LocaleString1("&c{1}&f was killed"), + @Comment("{1} - name of the hider who was found") + var found: LocaleString1 = LocaleString1("&e{1}&f was found"), + @Comment("{1} - name of the hider who was found") + @Comment("{2} - name of the seeker who found the hider") + var foundBy: LocaleString2 = LocaleString2("&e{1}&f was found by &c{2}&f"), +) + +data class LocaleGameGameoverConfig( + var hidersFound: String = "All hiders have been found", + @Comment("{1} - the name of the last hider") + var lastHider: LocaleString1 = LocaleString1("The last hider, &e{1}&f, has won!"), + var seekerQuit: String = "All seekers have quit", + var hiderQuit: String = "All hiders have quit", + var time: String = "Seekers have run out of time. Hiders win!", +) + +data class LocaleGameTitleConfig( + var hidersWin: String = "&aHiders Win!", + @Comment("{1} - the name of the hider who won") + var singleHiderWin: LocaleString1 = LocaleString1("&a{1} Wins!"), + var singleHiderWinSubtitle: LocaleString1 = LocaleString1("{1} is the last hider alive!"), + var seekersWin: String = "&cSeekers Win!", + var noWin: String = "&bGame Over", +) + +data class LocaleGameCountdownConfig( + @Comment("{1} - the amount of seconds hiders have left to hide") + var notify: LocaleString1 = LocaleString1("Hiders have {1} seconds left to hide!"), + var last: String = "Hiders have 1 second left to hide", +) + +data class LocaleGameTeamConfig( + var hider: String = "&6&lHIDER &r", + var seeker: String = "&c&lSEEKER &r", + var spectator: String = "&8&lSPECTATOR", + var hiderSubtitle: String = "Hide from the seekers", + var seekerSubtitle: String = "Find the hiders", + var spectatorSubtitle: String = "You've joined mid-game", +) + +data class LocaleGameConfig( + var player: LocaleGamePlayerConfig = LocaleGamePlayerConfig(), + var gameOver: LocaleGameGameoverConfig = LocaleGameGameoverConfig(), + var title: LocaleGameTitleConfig = LocaleGameTitleConfig(), + var countdown: LocaleGameCountdownConfig = LocaleGameCountdownConfig(), + var team: LocaleGameTeamConfig = LocaleGameTeamConfig(), + var setup: String = + "There are no maps setup! Run /hs map status on a map to see what you needto do", + var inGame: String = "You are already in the lobby/game", + var notInGame: String = "You are not in a lobby/game", + var inProgress: String = "There is currently a game in progress", + var notInProgress: String = "There is no game in progress", + var join: String = "You have joined mid game and are not a spectator", + @Comment("{1} - the name of the player who left the game") + var leave: LocaleString1 = LocaleString1("{1} has left the game"), + var start: String = "Attention SEEKERS, it's time to find the hiders!", + var stop: String = "The game has been forcefully stopped", + @Comment("{1} - the time till respawn") + var respawn: LocaleString1 = LocaleString1("You will respawn in {1} seconds"), +) + +data class LocaleSpectatorConfig( + var flyingEnabled: String = "&l&bFlying enabled", + var flyingDisabled: String = "&l&bFlying disabled", +) + +data class LocaleLobbyConfig( + @Comment("{1} - the name of the player who joined the lobby") + var join: LocaleString1 = LocaleString1("{1} has joined the lobby"), + @Comment("{1} - the name of the player who left the lobby") + var leave: LocaleString1 = LocaleString1("{1} has left the lobby"), + var inUse: String = "Can't modify the lobby while players are in it", + var full: String = "You cannot join the lobby since it is full", + @Comment("{1} - the minimum number of players required to start the game") + var notEnoughPlayers: LocaleString1 = + LocaleString1("You must have at least {1} players to start"), +) + +data class LocaleMapSaveConfig( + var start: String = "Starting map save", + var warning: String = + "All commands will be disabled when the save is in progress. Do not turn of the server.", + var inProgress: String = "Map save is currently in progress! Try again later.", + var finished: String = "Map save complete", + @Comment("{1} - the error message") + var failed: LocaleString1 = LocaleString1("Map save failed with the following error: {1}"), + var failedLocate: String = "Map save failed. Could not locate the map to save!", + var failedLoad: String = "Map save failed. Could not load the map!", + @Comment("{1} - the name of the directory that could not be renamed") + var failedDir: LocaleString1 = LocaleString1("Failed to rename/delete directory: {1}"), + var disabled: String = "Map saves are disabled in config.yml", +) + +data class LocaleMapSetupConfig( + @Comment("{1} - the map that is not yet setup") + var not: LocaleString1 = LocaleString1("Map {1} is not setup (/hs map status <map>)"), + var header: String = "&f&lThe following is needed for setup...", + var game: String = "&c&l- &fGame spawn isn't setup, /hs map set spawn <map>", + var lobby: String = "&c&l- &fLobby spawn isn't setup, /hs map set lobby <map>", + var seekerLobby: String = + "&c&l- &fSeeker Lobby spawn isn't setup, /hs map set seekerLobby <map>", + var exit: String = "&c&l- &fQuit/exit teleport location isn't set, /hs setexit", + var saveMap: String = "&c&l- &FMap isn't saved, /hs map save <map>", + var bounds: String = + "&c&l- &fPlease set game bounds in 2 opposite corners of the game map, /hs map set bounds <map>", + var blockHunt: String = + "&c&l - &fSince block hunt is enabled, there needs to be at least 1 block set, /hs map blockHunt block add block <map> <block>", + var complete: String = "Everything is setup and ready to go!", +) + +data class LocaleMapErrorConfig( + var locationNotSet: String = + "This location is not set (run /hs map status <map> for more info)", + var notInRange: String = "This position is out of range (check bounds or world border)", + var bounds: String = "Please set map bounds first", +) + +data class LocaleMapWarnConfig( + var gameSpawnReset: String = "Game spawn has been reset due to being out of range", + var seekerSpawnReset: String = "Seeker spawn has been reset due to being out of range", + var lobbySpawnReset: String = "Lobby spawn has been reset due to being out of range", +) + +data class LocaleMapSetConfig( + var gameSpawn: String = "Set game spawn position to your current position", + var seekerSpawn: String = "Set seeker spawn position to your current position", + var lobby: String = "Set lobby position to your current position", + var exit: String = "Set exit position to your current position", + @Comment("{1} - if the 1st or 2nd bound position was set") + var bounds: LocaleString1 = + LocaleString1("Successfully set bounds at your current position ({1}/2)"), +) + +data class LocaleMapConfig( + var save: LocaleMapSaveConfig = LocaleMapSaveConfig(), + var setup: LocaleMapSetupConfig = LocaleMapSetupConfig(), + var error: LocaleMapErrorConfig = LocaleMapErrorConfig(), + var warn: LocaleMapWarnConfig = LocaleMapWarnConfig(), + var set: LocaleMapSetConfig = LocaleMapSetConfig(), + var list: String = "The current maps are:", + var none: String = "There are no maps known to the plugin (/hs map add <name> <world>)", + var noneSetup: String = "There are no maps setup and ready to play", + var invalidName: String = "A map name can only contain ascii numbers and letters", + var wrongWorld: String = "Please run this command in the game world", + var exists: String = "A map with this name already exists!", + var unknown: String = "That map does not exist", + @Comment("{1} - the name of the new map") + var created: LocaleString1 = LocaleString1("Created map: {1}"), + @Comment("{1} - the name of the deleted map") + var deleted: LocaleString1 = LocaleString1("Deleted map: {1}"), +) + +data class LocaleWorldBorderConfig( + var disable: String = "Disabled world border", + var minSize: String = "World border cannot be smaller than 100 blocks", + var minChange: String = "World border move be able to move", + var position: String = "Spawn position must be 100 from world border center", + @Comment("{1} - the new size of the world border") + @Comment("{2} - the new delay of the world border") + @Comment("{3} - how much the border changes at a time") + var enable: LocaleString3 = + LocaleString3( + "Set border center to current location, size to {1}, delay to {2}, and steps by {3} blocks" + ), + var warn: String = "World border will shrink in the next 30s!", + var shrinking: String = "&c&oWorld border is shrinking!", +) + +data class LocaleTauntConfig( + var chosen: String = "&c&oOh no! You have been chosen to be taunted", + var warning: String = "A random hider will be taunted in the next 30s", + var activate: String = "Taunt has been activated", +) + +data class LocaleBlockHuntBlockConfig( + @Comment("{1} - the block trying to be added to the block hunt map") + var exists: LocaleString1 = LocaleString1("{1} has already been added to this map"), + @Comment("{1} - the block trying to be removed from the block hunt map") + var doesntExist: LocaleString1 = LocaleString1("{1} is already not used for the map"), + @Comment("{1} - the block added to the block hunt map") + var added: LocaleString1 = LocaleString1("Added {1} as a disguise to the map"), + @Comment("{1} - the block removed from the block hunt map") + var removed: LocaleString1 = LocaleString1("Removed {1} as a disguise from the map"), + var list: String = "The block disguises for the map are:", + var none: String = "There are no block disguises in use for this map", + var unknown: String = "This block name does not exist", +) + +data class LocaleBlockHuntConfig( + var notEnabled: String = "Block hunt is not enabled on ths map", + var notSupported: String = "Block hunt does not work on 1.8", + var enabled: String = "Block hunt has been enabled", + var disabled: String = "Block hunt has been disabled", + var block: LocaleBlockHuntBlockConfig = LocaleBlockHuntBlockConfig(), +) + +data class LocaleWorldConfig( + @Comment("{1} - the world name") + var exists: LocaleString1 = LocaleString1("A world named {1} already exists"), + @Comment("{1} - the world name") + var doesntExist: LocaleString1 = LocaleString1("There is not world named {1}"), + @Comment("{1} - the world name") + var added: LocaleString1 = LocaleString1("Created a world named {1}"), + var addedFailed: LocaleString1 = LocaleString1("Failed to create a world named {1}"), + @Comment("{1} - the world name") + var removed: LocaleString1 = LocaleString1("Removed the world named {1}"), + var removedFailed: LocaleString1 = LocaleString1("Failed to remove the world named {1}"), + @Comment("{1} - the world name") + @Comment("{2} - the map using the world") + var inUseBy: LocaleString2 = LocaleString2("The world {1} is in use by map {2}"), + var inUse: LocaleString1 = LocaleString1("The world {1} is in use by the plugin"), + @Comment("{1} - the world name") + var loadFailed: LocaleString1 = LocaleString1("Failed to load: {1}"), + @Comment("{1} - the given world type") + var invalidType: LocaleString1 = LocaleString1("Invalid world type: {1}"), + var notEmpty: String = "World must be empty to be deleted", + var list: String = "The following worlds are", + var none: String = "Failed to fetch any worlds", +) + +data class LocaleDatabaseConfig( + var noInfo: String = "No gameplay info", + @Comment("{1} - the player associated with the following win information") + var infoFor: LocaleString1 = LocaleString1("Win information for {1}:"), +) + +data class LocaleConfirmConfig( + var none: String = "You have nothing to confirm", + var timedOut: String = "The confirmation has timed out", + var confirm: String = "Run /hs confirm within 10s to confirm", +) + +data class KhsLocale( + @Section("Language") @Comment("What language is this for?") var locale: String = "en_US", + @Section("Message prefixes") + @Comment("Specify prefixes for plugin chat messages.") + var prefix: LocalePrefixConfig = LocalePrefixConfig(), + @Section("Placeholder errors") + @Comment("PlaceholderAPI error strings") + var placeholder: LocalePlaceholderConfig = LocalePlaceholderConfig(), + @Section("Command responses") var command: LocaleCommandConfig = LocaleCommandConfig(), + @Section("Gameplay") var game: LocaleGameConfig = LocaleGameConfig(), + @Section("Spectator") var spectator: LocaleSpectatorConfig = LocaleSpectatorConfig(), + @Section("Lobby") var lobby: LocaleLobbyConfig = LocaleLobbyConfig(), + @Section("Map") var map: LocaleMapConfig = LocaleMapConfig(), + @Section("World Border") var worldBorder: LocaleWorldBorderConfig = LocaleWorldBorderConfig(), + @Section("Taunt event") var taunt: LocaleTauntConfig = LocaleTauntConfig(), + @Section("Block Hunt") var blockHunt: LocaleBlockHuntConfig = LocaleBlockHuntConfig(), + @Section("World") var world: LocaleWorldConfig = LocaleWorldConfig(), + @Section("Database") var database: LocaleDatabaseConfig = LocaleDatabaseConfig(), + @Section("Confirm") var confirm: LocaleConfirmConfig = LocaleConfirmConfig(), +) diff --git a/core/src/config/Maps.kt b/core/src/config/Maps.kt new file mode 100644 index 0000000..9282bea --- /dev/null +++ b/core/src/config/Maps.kt @@ -0,0 +1,58 @@ +package cat.freya.khs.config + +import cat.freya.khs.world.Position + +data class LegacyPosition( + var x: Double = 0.0, + var y: Double = 0.0, + var z: Double = 0.0, + @Omittable @KhsDeprecated("2.0.0") var world: String? = null, +) { + fun toPosition(): Position = Position(x, y, z) +} + +data class SpawnsConfig( + // 1.x series of KHS stored game world in the positions + // so we need to load it in to be able to migrate + // it + var game: LegacyPosition? = null, + var lobby: Position? = null, + var seeker: Position? = null, +) + +data class BoundConfig(var x: Double = 0.0, var z: Double = 0.0) + +data class BoundsConfig(var min: BoundConfig? = null, var max: BoundConfig? = null) + +data class WorldBorderConfig( + var enabled: Boolean = false, + var pos: Position? = null, + var size: ULong? = null, + var delay: ULong? = null, + var move: ULong? = null, +) + +data class BlockHuntConfig(var enabled: Boolean = false, var blocks: List<String> = emptyList()) + +data class MapConfig( + var world: String? = null, + var spawns: SpawnsConfig = SpawnsConfig(), + var bounds: BoundsConfig = BoundsConfig(), + var worldBorder: WorldBorderConfig = WorldBorderConfig(), + var blockHunt: BlockHuntConfig = BlockHuntConfig(), +) { + fun migrate() { + // migrate from v1 world + if (world != null) return + + // move world name + world = spawns.game?.world + spawns.game?.world = null + } +} + +data class KhsMapsConfig( + @Comment("DO NOT EDIT THIS FILE - It is autogenerated") + @Comment("Please use /hs map ... commands instead") + var maps: Map<String, MapConfig> = emptyMap() +) diff --git a/core/src/config/util/Deserialize.kt b/core/src/config/util/Deserialize.kt new file mode 100644 index 0000000..8a5be59 --- /dev/null +++ b/core/src/config/util/Deserialize.kt @@ -0,0 +1,134 @@ +package cat.freya.khs.config.util + +import cat.freya.khs.config.LocaleString1 +import cat.freya.khs.config.LocaleString2 +import cat.freya.khs.config.LocaleString3 +import java.io.InputStream +import java.io.InputStreamReader +import java.io.Reader +import kotlin.reflect.KClass +import kotlin.reflect.KMutableProperty1 +import kotlin.reflect.full.createInstance +import kotlin.reflect.full.declaredFunctions +import kotlin.reflect.full.isSubclassOf +import kotlin.reflect.full.memberProperties +import org.yaml.snakeyaml.Yaml + +fun <T : Any> deserializeClass(type: KClass<T>, data: Map<String, Any?>): T { + require(type.isData) { "$type is not a data class" } + + val propValues = + type.memberProperties.associateWith { prop -> + val value = data[prop.name] ?: return@associateWith null + val propType = prop.returnType.classifier as KClass<*> + val innerTypes = + prop.returnType.arguments.map { it.type?.classifier as? KClass<*> }.filterNotNull() + deserializeField(propType, innerTypes, prop.name, value) + } + + val instance = type.createInstance() + for ((prop, value) in propValues) { + if (value != null) { + (prop as? KMutableProperty1<*, *>)?.setter?.call(instance, value) + ?: error("${prop.name} is not mutable") + } + } + + val migrateFunction = instance::class.declaredFunctions.singleOrNull { it.name == "migrate" } + if (migrateFunction != null) migrateFunction.call(instance) + + return instance +} + +fun <T : Enum<*>> deserializeEnum(type: KClass<T>, key: String, value: String): T { + return type.java.enumConstants.firstOrNull { it.name == value } + ?: error("$key: invalid enum value of '$value'") +} + +fun <T : Any> deserializeList(innerType: KClass<T>, key: String, value: List<*>): List<T> { + return value.map { deserializeField<T>(innerType, null, key, it) } +} + +fun <K : Any, V : Any> deserializeMap( + keyType: KClass<K>, + valueType: KClass<V>, + key: String, + value: Map<*, *>, +): Map<String, V> { + if (keyType != String::class) error("maps may only contain strings as keys") + + return value + .mapKeys { deserializePrimitive(key, String::class, it.key ?: "") } + .mapValues { deserializeField(valueType, null, key, it.value) } +} + +@Suppress("UNCHECKED_CAST") +fun <T : Any> deserializePrimitive(key: String, expected: KClass<T>, value: Any): T { + return when { + expected == String::class && value is String -> value as T + expected == LocaleString1::class && value is String -> LocaleString1(value) as T + expected == LocaleString2::class && value is String -> LocaleString2(value) as T + expected == LocaleString3::class && value is String -> LocaleString3(value) as T + expected == Int::class && value is Number -> value.toInt() as T + expected == UInt::class && value is Number -> maxOf(0, value.toInt()).toUInt() as T + expected == Long::class && value is Number -> value.toLong() as T + expected == ULong::class && value is Number -> maxOf(0L, value.toLong()).toULong() as T + expected == Float::class && value is Number -> value.toFloat() as T + expected == Double::class && value is Number -> value.toDouble() as T + expected == Boolean::class && value is Boolean -> value as T + expected == Boolean::class && value is Number -> (value.toInt() != 0) as T + else -> error("$key: invalid value '$value' for type $expected") + } +} + +@Suppress("UNCHECKED_CAST") +fun <T : Any> deserializeField( + type: KClass<T>, + innerTypes: List<KClass<*>>?, + key: String, + value: Any?, +): T { + return when { + type.isData -> + deserializeClass<T>( + type, + value as? Map<String, Any?> ?: error("$key: expected map for data class $type"), + ) + + type.java.isEnum -> + deserializeEnum( + type as KClass<Enum<*>>, + key, + value as? String ?: error("$key: expected string for enum value"), + ) + as T + + type.isSubclassOf(List::class) -> + deserializeList( + innerTypes?.firstOrNull() ?: error("$key: innerType not set"), + key, + value as? List<*> ?: error("$key: expected list for type $type"), + ) + as T + + type.isSubclassOf(Map::class) -> + deserializeMap( + innerTypes?.firstOrNull() ?: error("key type not set"), + innerTypes.getOrNull(1) ?: error("value type not set"), + key, + value as? Map<*, *> ?: error("$key: expected map for type $type"), + ) + as T + + else -> deserializePrimitive(key, type, value ?: error("$key: value cannot be null")) + } +} + +fun <T : Any> deserialize(type: KClass<T>, ins: InputStream?): T { + val reader = ins?.let { InputStreamReader(it) } ?: return type.createInstance() + return deserialize(type, reader) +} + +fun <T : Any> deserialize(type: KClass<T>, ins: Reader): T { + return deserializeClass(type, Yaml().load(ins)) +} diff --git a/core/src/config/util/Serialize.kt b/core/src/config/util/Serialize.kt new file mode 100644 index 0000000..1ac5f1a --- /dev/null +++ b/core/src/config/util/Serialize.kt @@ -0,0 +1,190 @@ +package cat.freya.khs.config.util + +import cat.freya.khs.config.Comment +import cat.freya.khs.config.KhsDeprecated +import cat.freya.khs.config.LocaleString1 +import cat.freya.khs.config.LocaleString2 +import cat.freya.khs.config.LocaleString3 +import cat.freya.khs.config.Omittable +import cat.freya.khs.config.Section +import kotlin.reflect.full.isSubclassOf +import kotlin.reflect.full.memberProperties +import kotlin.reflect.full.primaryConstructor +import kotlin.text.buildString +import org.yaml.snakeyaml.DumperOptions +import org.yaml.snakeyaml.Yaml + +fun typeInline(value: Any?): Boolean { + if (value == null) return true + + return when (value) { + is List<*> -> value.all { typeInline(it) } + is Map<*, *> -> value.isEmpty() + is Boolean -> true + value::class.isData -> false + else -> true + } +} + +fun serializeSection(section: Section): String { + val width = 100 + val prefixWidth = 3 + val headerWidth = section.text.length + val slugWidth = width - prefixWidth - headerWidth + + return buildString { + appendLine() // spacing + + // top line + append("#") + append(" ".repeat(prefixWidth)) + append("┌") + append("─".repeat(headerWidth + 2)) + appendLine("┐") + + // bottom line + append("#") + append("─".repeat(prefixWidth)) + append("┘ ${section.text} └") + appendLine("─".repeat(slugWidth)) + + appendLine() // spacing + } +} + +fun serializeComment(comment: Comment): String { + return buildString { + for (line in comment.text.lines()) { + appendLine("# $line") + } + } +} + +fun serializeDeprecated(deprecated: KhsDeprecated): String { + return "Warning: This field has been DEPRECATED since ${deprecated.since}" +} + +fun <T : Any> serializeClass(instance: T): String { + val type = instance::class + require(type.isData) { "$type is not a data class" } + + val propValues = + type.primaryConstructor!! + .parameters + .map { param -> type.memberProperties.find { it.name == param.name } } + .filterNotNull() + .associateWith { prop -> prop.getter.call(instance) } + + return buildString { + for ((prop, value) in propValues) { + if (value == null && prop.annotations.contains(Omittable())) continue + + val lines = serialize(value).trim().lines().filter { it.isNotEmpty() } + + // append comments + for (annotation in prop.annotations) { + when (annotation) { + is Section -> append(serializeSection(annotation)) + is Comment -> append(serializeComment(annotation)) + is KhsDeprecated -> append(serializeDeprecated(annotation)) + } + } + + // no content, then skip + if (lines.isEmpty()) continue + + // no indentation if only a single item + if (lines.size == 1 && typeInline(value)) { + appendLine("${prop.name}: ${lines[0]}") + continue + } + + appendLine("${prop.name}:") + for (line in lines) { + appendLine(" $line") + } + } + } +} + +fun <T : Any?> serializeList(list: List<T>): String { + if (list.isEmpty()) return "[]" + + if (list.size == 1 && typeInline(list)) { + val text = serialize(list[0]) + return "[$text]" + } + + return buildString { + for (value in list) { + val lines = serialize(value).trim().lines().filter { it.isNotEmpty() } + for ((i, line) in lines.withIndex()) { + append(if (i == 0) "- " else " ") + appendLine(line) + } + } + } +} + +fun <K : Any?, V : Any?> serializeMap(map: Map<K, V>): String { + if (map.isEmpty()) return "{}" + + return buildString { + for ((key, value) in map) { + if (key !is String) error("Map values must be strings") + val keyString = key.toString() + val lines = serialize(value).trim().lines().filter { it.isNotEmpty() } + + if (lines.isEmpty()) continue + + if (lines.size == 1 && typeInline(value)) { + appendLine("$keyString: ${lines[0]}") + continue + } + + appendLine("$keyString:") + for (line in lines) { + append(" ") + appendLine(line) + } + } + } +} + +fun <T : Any> serializePrimitive(value: T): String { + val stringYaml = + Yaml( + DumperOptions().apply { + defaultScalarStyle = DumperOptions.ScalarStyle.SINGLE_QUOTED + splitLines = false + } + ) + val yaml = Yaml() + return when { + value is String -> stringYaml.dump(value) + value is LocaleString1 -> stringYaml.dump(value.inner) + value is LocaleString2 -> stringYaml.dump(value.inner) + value is LocaleString3 -> stringYaml.dump(value.inner) + value is Int -> yaml.dump(value) + value is UInt -> yaml.dump(value.toInt()) + value is Long -> yaml.dump(value) + value is ULong -> yaml.dump(value.toLong()) + value is Boolean -> yaml.dump(value) + value is Float -> yaml.dump(value) + value is Double -> yaml.dump(value) + else -> error("cannot serialize '$value'") + }.trim() +} + +fun <T : Any> serialize(value: T?): String { + if (value == null) return "null" + + val type = value::class + return when { + type.isData -> serializeClass(value) + type.java.isEnum -> value.toString() + type.isSubclassOf(List::class) -> serializeList(value as List<*>) + type.isSubclassOf(Map::class) -> serializeMap(value as Map<*, *>) + else -> serializePrimitive(value) + } +} diff --git a/core/src/db/Database.kt b/core/src/db/Database.kt new file mode 100644 index 0000000..27864b3 --- /dev/null +++ b/core/src/db/Database.kt @@ -0,0 +1,80 @@ +package cat.freya.khs.db + +import cat.freya.khs.Khs +import java.util.UUID +import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.jdbc.* +import org.jetbrains.exposed.v1.jdbc.Database as Exposed +import org.jetbrains.exposed.v1.jdbc.transactions.transaction + +class Database(plugin: Khs) { + val driver = getDriver(plugin) + val source = driver.connect() + val db = Exposed.connect(source) + + init { + transaction(db) { SchemaUtils.create(Players) } + migrateLegacy() + } + + fun getPlayer(uuid: UUID): Player? = + transaction(db) { + val id = uuid.toString() + Players.selectAll().where { Players.uuid eq id }.map { it.toPlayer() }.singleOrNull() + } + + fun getPlayer(name: String): Player? = + transaction(db) { + Players.selectAll().where { Players.name eq name }.map { it.toPlayer() }.singleOrNull() + } + + fun getPlayers(page: UInt, pageSize: UInt): List<Player> = + transaction(db) { + val offset = page * pageSize + val wins = Players.hiderWins + Players.seekerWins + Players.selectAll() + .orderBy(wins to SortOrder.DESC) + .limit(pageSize.toInt()) + .offset(offset.toLong()) + .map { it.toPlayer() } + } + + fun getPlayerNames(limit: UInt, startsWith: String): List<String> = + transaction(db) { + Players.select(Players.name) + .where { Players.name like "$startsWith%" } + .orderBy(Players.name to SortOrder.ASC) + .limit(limit.toInt()) + .map { it[Players.name] } + .filterNotNull() + } + + fun upsertPlayer(player: Player) = transaction(db) { Players.upsert { it.fromPlayer(player) } } + + fun upsertName(u: UUID, n: String) = + transaction(db) { + Players.upsert { + it[uuid] = u.toString() + it[name] = n + } + } + + fun migrateLegacy() = + transaction(db) { + if (!LegacyPlayers.exists() || !LegacyNames.exists()) return@transaction + + val legacy = + LegacyPlayers.join( + LegacyNames, + JoinType.LEFT, + onColumn = LegacyPlayers.uuid, + otherColumn = LegacyNames.uuid, + ) + .selectAll() + .map { it.toLegacyPlayer() } + Players.insertIgnore { legacy.forEach { player -> it.fromPlayer(player) } } + + SchemaUtils.drop(LegacyPlayers) + SchemaUtils.drop(LegacyNames) + } +} diff --git a/core/src/db/Driver.kt b/core/src/db/Driver.kt new file mode 100644 index 0000000..c30e5c1 --- /dev/null +++ b/core/src/db/Driver.kt @@ -0,0 +1,79 @@ +package cat.freya.khs.db + +import cat.freya.khs.Khs +import cat.freya.khs.config.DatabaseConfig +import cat.freya.khs.config.DatabaseType +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import javax.sql.DataSource + +abstract class Driver { + abstract val driverClass: String + + abstract fun jdbcUrl(): String + + abstract fun configure(hikari: HikariConfig) + + fun connect(): DataSource { + // load driver for some reason + Class.forName(driverClass) + + val cores = Runtime.getRuntime().availableProcessors() + val hikari = + HikariConfig().apply { + jdbcUrl = jdbcUrl() + driverClassName = driverClass + maximumPoolSize = minOf(cores, 8) + configure(this) + } + return HikariDataSource(hikari) + } +} + +class SqliteDriver(val path: String) : Driver() { + override val driverClass = "org.sqlite.JDBC" + + override fun jdbcUrl(): String { + return "jdbc:sqlite:$path" + } + + override fun configure(hikari: HikariConfig) { + // sqlite is single threaded + hikari.maximumPoolSize = 1 + } +} + +class MysqlDriver(val config: DatabaseConfig) : Driver() { + override val driverClass = "com.mysql.cj.jdbc.Driver" + + override fun jdbcUrl(): String { + val port = config.port ?: 3006u + return "jdbc:mysql://${config.host}:${port}/${config.database}" + } + + override fun configure(hikari: HikariConfig) { + hikari.username = config.username + hikari.password = config.password + } +} + +class PostgresDriver(val config: DatabaseConfig) : Driver() { + override val driverClass = "org.postgresql.Driver" + + override fun jdbcUrl(): String { + val port = config.port ?: 5432u + return "jdbc:postgresql://${config.host}:${port}/${config.database}" + } + + override fun configure(hikari: HikariConfig) { + hikari.username = config.username + hikari.password = config.password + } +} + +fun getDriver(plugin: Khs): Driver = + when (plugin.config.database.type) { + DatabaseType.SQLITE -> SqliteDriver(plugin.shim.sqliteDatabasePath) + DatabaseType.MYSQL -> MysqlDriver(plugin.config.database) + DatabaseType.POSTGRES -> PostgresDriver(plugin.config.database) + } diff --git a/core/src/db/Legacy.kt b/core/src/db/Legacy.kt new file mode 100644 index 0000000..7f64ef2 --- /dev/null +++ b/core/src/db/Legacy.kt @@ -0,0 +1,58 @@ +package cat.freya.khs.db + +import java.nio.ByteBuffer +import java.util.UUID +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.Table + +// tables introduced in version 1.7.0 +// pre 1.7.x tables are NOT SUPPORTED + +object LegacyNames : Table("hs_names") { + val uuid = binary("uuid", 16) + val name = varchar("name", 48).nullable() + override val primaryKey = PrimaryKey(uuid, name) +} + +object LegacyPlayers : Table("hs_data") { + val uuid = binary("uuid", 16) + val hiderWins = integer("hider_wins").nullable() + val seekerWins = integer("seeker_wins").nullable() + val hiderGames = integer("hider_games").nullable() + val seekerGames = integer("seeker_games").nullable() + val hiderKills = integer("hider_kills").nullable() + val seekerKills = integer("seeker_kills").nullable() + val hiderDeaths = integer("hider_deaths").nullable() + val seekerDeaths = integer("seeker_deaths").nullable() +} + +fun ResultRow.toLegacyPlayer(): Player { + val uuidBuffer = ByteBuffer.wrap(this[LegacyPlayers.uuid]) + val uuidHigh = uuidBuffer.long + val uuidLow = uuidBuffer.long + val uuid = UUID(uuidHigh, uuidLow) + + val hiderGames = this[LegacyPlayers.hiderGames] ?: 0 + val seekerGames = this[LegacyPlayers.seekerGames] ?: 0 + val hiderWins = this[LegacyPlayers.hiderWins] ?: 0 + val seekerWins = this[LegacyPlayers.seekerWins] ?: 0 + val hiderLosses = hiderGames - hiderWins + val seekerLosses = seekerGames - seekerWins + val hiderKills = this[LegacyPlayers.hiderKills] ?: 0 + val seekerKills = this[LegacyPlayers.seekerKills] ?: 0 + val hiderDeaths = this[LegacyPlayers.hiderDeaths] ?: 0 + val seekerDeaths = this[LegacyPlayers.seekerDeaths] ?: 0 + + return Player( + uuid, + name = this[LegacyNames.name], + seekerWins = maxOf(seekerWins, 0).toUInt(), + hiderWins = maxOf(hiderWins, 0).toUInt(), + hiderLosses = maxOf(hiderLosses, 0).toUInt(), + seekerLosses = maxOf(seekerLosses, 0).toUInt(), + seekerKills = maxOf(seekerKills, 0).toUInt(), + hiderKills = maxOf(hiderKills, 0).toUInt(), + seekerDeaths = maxOf(seekerDeaths, 0).toUInt(), + hiderDeaths = maxOf(hiderDeaths, 0).toUInt(), + ) +} diff --git a/core/src/db/Player.kt b/core/src/db/Player.kt new file mode 100644 index 0000000..ccebcaa --- /dev/null +++ b/core/src/db/Player.kt @@ -0,0 +1,62 @@ +package cat.freya.khs.db + +import java.util.UUID +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.statements.UpdateBuilder + +object Players : Table("hs_players") { + val uuid = varchar("uuid", 36) + val name = text("name").nullable() + val seekerWins = integer("seeker_wins").default(0) + val hiderWins = integer("hider_wins").default(0) + val seekerLosses = integer("seeker_losses").default(0) + val hiderLosses = integer("hider_losses").default(0) + val seekerKills = integer("seeker_kills").default(0) + val hiderKills = integer("hider_kills").default(0) + val seekerDeaths = integer("seeker_deaths").default(0) + val hiderDeaths = integer("hider_deaths").default(0) + + override val primaryKey = PrimaryKey(uuid) +} + +data class Player( + val uuid: UUID, + var name: String? = null, + var seekerWins: UInt = 0u, + var hiderWins: UInt = 0u, + var seekerLosses: UInt = 0u, + var hiderLosses: UInt = 0u, + var seekerKills: UInt = 0u, + var hiderKills: UInt = 0u, + var seekerDeaths: UInt = 0u, + var hiderDeaths: UInt = 0u, +) + +fun ResultRow.toPlayer(): Player { + return Player( + uuid = UUID.fromString(this[Players.uuid]), + name = this[Players.name], + seekerWins = this[Players.seekerWins].toUInt(), + hiderWins = this[Players.hiderWins].toUInt(), + seekerLosses = this[Players.seekerLosses].toUInt(), + hiderLosses = this[Players.hiderLosses].toUInt(), + seekerKills = this[Players.seekerKills].toUInt(), + hiderKills = this[Players.hiderKills].toUInt(), + seekerDeaths = this[Players.seekerDeaths].toUInt(), + hiderDeaths = this[Players.hiderDeaths].toUInt(), + ) +} + +fun UpdateBuilder<*>.fromPlayer(player: Player) { + this[Players.uuid] = player.uuid.toString() + this[Players.name] = player.name + this[Players.seekerWins] = player.seekerWins.toInt() + this[Players.hiderWins] = player.hiderWins.toInt() + this[Players.seekerLosses] = player.seekerLosses.toInt() + this[Players.hiderLosses] = player.hiderLosses.toInt() + this[Players.seekerKills] = player.seekerKills.toInt() + this[Players.hiderKills] = player.hiderKills.toInt() + this[Players.seekerDeaths] = player.seekerDeaths.toInt() + this[Players.hiderDeaths] = player.hiderDeaths.toInt() +} diff --git a/core/src/events/Event.kt b/core/src/events/Event.kt new file mode 100644 index 0000000..87ba012 --- /dev/null +++ b/core/src/events/Event.kt @@ -0,0 +1,9 @@ +package cat.freya.khs.event + +abstract class Event { + var cancelled: Boolean = false + + fun cancel() { + cancelled = true + } +} diff --git a/core/src/events/onBreak.kt b/core/src/events/onBreak.kt new file mode 100644 index 0000000..2f2e490 --- /dev/null +++ b/core/src/events/onBreak.kt @@ -0,0 +1,15 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class BreakEvent(val plugin: Khs, val player: Player, val material: String) : Event() + +fun onBreak(event: BreakEvent) { + val (plugin, player, _) = event + val game = plugin.game + + if (!game.hasPlayer(player)) return + + event.cancel() +} diff --git a/core/src/events/onChat.kt b/core/src/events/onChat.kt new file mode 100644 index 0000000..4660365 --- /dev/null +++ b/core/src/events/onChat.kt @@ -0,0 +1,22 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class ChatEvent(val plugin: Khs, val player: Player, val msg: String) : Event() + +fun onChat(event: ChatEvent) { + val (plugin, player, msg) = event + val game = plugin.game + + if (!game.isSpectator(player)) return + + // only allow spectators to chat + // with eachother + event.cancel() + game.spectatorPlayers.forEach { + val team = plugin.locale.game.team.spectator + val name = player.name + it.message("$team&f <$name> $msg") + } +} diff --git a/core/src/events/onClick.kt b/core/src/events/onClick.kt new file mode 100644 index 0000000..a8883bd --- /dev/null +++ b/core/src/events/onClick.kt @@ -0,0 +1,75 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.game.Game +import cat.freya.khs.inv.* +import cat.freya.khs.player.Inventory +import cat.freya.khs.player.Player +import cat.freya.khs.world.Item +import kotlin.text.startsWith + +data class ClickEvent( + val plugin: Khs, + val player: Player, + val inventory: Inventory, + val clicked: Item, +) : Event() + +private fun onClickSpectator(event: ClickEvent) { + val (plugin, player, _, item) = event + val name = item.name ?: return + event.cancel() + + // teleport to player + if (item.similar("PLAYER_HEAD")) { + player.closeInventory() + + val clicked = plugin.shim.getPlayer(name) ?: return + player.teleport(clicked.location) + return + } + + // change page + if (item.similar("ENCHANTED_BOOK") && name.startsWith("Page ")) { + player.closeInventory() + + val page = name.substring(5).toUIntOrNull() ?: return + val inv = createTeleportMenu(plugin, page - 1u) ?: return + player.showInventory(inv) + } +} + +private fun onClickDebug(event: ClickEvent) { + val (plugin, player, _, item) = event + event.cancel() + + if (item.similar(BECOME_SEEKER)) becomeSeeker(plugin, player) + else if (item.similar(BECOME_HIDER)) becomeHider(plugin, player) + else if (item.similar(BECOME_SPECTATOR)) becomeSpectator(plugin, player) + else if (item.similar(DIE_IN_GAME)) dieInGame(plugin, player) + else if (item.similar(REVEAL_DISGUISE)) player.revealDisguise() else return + + player.closeInventory() +} + +private fun onClickBlockHunt(event: ClickEvent) { + event.cancel() + + val material = event.clicked.material + event.player.disguise(material) + event.player.closeInventory() +} + +fun onClick(event: ClickEvent) { + val (plugin, player, inv, _) = event + val game = plugin.game + + // dont allow interactions in the lobby + if (game.hasPlayer(player) && game.status == Game.Status.LOBBY) event.cancel() + + if (game.isSpectator(player)) onClickSpectator(event) + + if (inv.title == DEBUG_TITLE) onClickDebug(event) + + if (inv.title?.startsWith("Select a Block: ") == true) onClickBlockHunt(event) +} diff --git a/core/src/events/onClose.kt b/core/src/events/onClose.kt new file mode 100644 index 0000000..697eae4 --- /dev/null +++ b/core/src/events/onClose.kt @@ -0,0 +1,21 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.inv.BLOCKHUNT_TITLE_PREFIX +import cat.freya.khs.player.Inventory +import cat.freya.khs.player.Player +import kotlin.text.startsWith + +data class CloseEvent(val plugin: Khs, val player: Player, val inventory: Inventory) : Event() + +fun onClose(event: CloseEvent) { + val (plugin, player, inv) = event + val game = plugin.game + + // only block hunt matters here + if (inv.title?.startsWith(BLOCKHUNT_TITLE_PREFIX) != true) return + + val blocks = game.map?.config?.blockHunt?.blocks ?: return + val defaultBlock = blocks.firstOrNull() ?: return + if (!player.isDisguised()) player.disguise(defaultBlock) +} diff --git a/core/src/events/onCommand.kt b/core/src/events/onCommand.kt new file mode 100644 index 0000000..fc3dddb --- /dev/null +++ b/core/src/events/onCommand.kt @@ -0,0 +1,20 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.game.Game +import cat.freya.khs.player.Player + +data class CommandEvent(val plugin: Khs, val player: Player, val msg: String) : Event() + +fun onCommand(event: CommandEvent) { + val (plugin, player, msg) = event + val game = plugin.game + + if (!game.hasPlayer(player) || game.status == Game.Status.LOBBY) return + + val invoke = msg.split(" ").firstOrNull()?.lowercase() ?: return + if (!plugin.config.blockedCommands.any { it.lowercase() == invoke }) return + + event.cancel() + player.message(plugin.locale.prefix.error + plugin.locale.command.notAllowedTemp) +} diff --git a/core/src/events/onDamage.kt b/core/src/events/onDamage.kt new file mode 100644 index 0000000..1377922 --- /dev/null +++ b/core/src/events/onDamage.kt @@ -0,0 +1,128 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.game.Game +import cat.freya.khs.player.Player + +data class DamageEvent( + val plugin: Khs, + val player: Player, + val attacker: Player?, + val damage: Double, +) : Event() + +/// handles when a player in the game is damaged +fun onDamage(event: DamageEvent) { + val (plugin, player, attacker, damage) = event + val game = plugin.game + + // make sure that the attacker (if exists) is allowed to damage us + if (attacker != null) { + // players must both be in the game + if ( + (game.hasPlayer(player) && !game.hasPlayer(attacker)) || + (game.hasPlayer(attacker) && !game.hasPlayer(player)) + ) { + event.cancel() + return + } + + // players cant be on the same team + if (game.sameTeam(player.uuid, attacker.uuid)) { + event.cancel() + return + } + + // cannot attack spectators + if (game.isSpectator(player) || game.isSpectator(attacker)) { + event.cancel() + return + } + + // ignore if pvp is diabled, and a hider is trying to attack a seeker + if (!plugin.config.pvp && game.isHider(attacker) && game.isSeeker(player)) { + event.cancel() + return + } + // if there is no attacker, and the player is not in game, we do not care + } else if (!game.hasPlayer(player)) { + return + // if there is no attacker, it most of been by natural causes... + // if pvp is disabled, and config doesn't allow natural causes, cancel event + } else if (!plugin.config.pvp && !plugin.config.allowNaturalCauses) { + event.cancel() + return + } + + // spectators cannot take damage + if (game.isSpectator(player)) { + event.cancel() + val world = player.world ?: return + if (player.location.y < world.minY) { + // make sure they dont try to kill them self to the void lol + game.map?.gameSpawn?.teleport(player) + } + } + + // cant take damage until seeking + if (game.status != Game.Status.SEEKING) { + event.cancel() + return + } + + // check if player dies (pvp mode) + // if not then it is fine (if so we need to handle it) + if (plugin.config.pvp && player.health - damage >= 0.5) return + + /* handle death event (player was tagged or killed in pvp) */ + event.cancel() + + // play death sound + player.playSound( + if (plugin.shim.supports(9)) "ENTITY_PLAYER_DEATH" else "ENTITY_PLAYER_HURT", + 1.0, + 1.0, + ) + + // reveal a player if their disguised + player.revealDisguise() + + // respawn player + if (plugin.config.delayedRespawn.enabled && !plugin.config.respawnAsSpectator) { + val time = plugin.config.delayedRespawn.delay + game.map?.seekerLobbySpawn?.teleport(player) + player.message(plugin.locale.prefix.default + plugin.locale.game.respawn.with(time)) + plugin.shim.scheduleEvent(time * 20UL) { + if (game.status == Game.Status.SEEKING) game.map?.gameSpawn?.teleport(player) + } + } else { + game.map?.gameSpawn?.teleport(player) + } + + // update leaderboard + game.addDeath(player.uuid) + if (attacker != null) game.addKill(attacker.uuid) + + // broadcast death and update team + if (game.isSeeker(player)) { + game.broadcast(plugin.locale.game.player.death.with(player.name)) + } else { + val msg = + if (attacker == null) { + plugin.locale.game.player.found.with(player.name) + } else { + plugin.locale.game.player.foundBy.with(player.name, attacker.name) + } + game.broadcast(msg) + + // reset player team and items + if (plugin.config.respawnAsSpectator) { + game.setTeam(player.uuid, Game.Team.SPECTATOR) + game.loadSpectator(player) + } else { + game.setTeam(player.uuid, Game.Team.SEEKER) + game.resetPlayer(player) + game.giveSeekerItems(player) + } + } +} diff --git a/core/src/events/onDeath.kt b/core/src/events/onDeath.kt new file mode 100644 index 0000000..250b55c --- /dev/null +++ b/core/src/events/onDeath.kt @@ -0,0 +1,18 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class DeathEvent(val plugin: Khs, val player: Player) : Event() + +fun onDeath(event: DeathEvent) { + val (plugin, player) = event + val game = plugin.game + + // uh, if u dead, kinda arent disguised anymore lol + player.revealDisguise() + + if (!game.hasPlayer(player)) return + + event.cancel() +} diff --git a/core/src/events/onDrop.kt b/core/src/events/onDrop.kt new file mode 100644 index 0000000..3abc9ae --- /dev/null +++ b/core/src/events/onDrop.kt @@ -0,0 +1,16 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player +import cat.freya.khs.world.Item + +data class DropEvent(val plugin: Khs, val player: Player, val item: Item) : Event() + +fun onDrop(event: DropEvent) { + val (plugin, player, _) = event + val game = plugin.game + + if (!game.hasPlayer(player)) return + + if (!plugin.config.dropItems) event.cancel() +} diff --git a/core/src/events/onHunger.kt b/core/src/events/onHunger.kt new file mode 100644 index 0000000..72995fc --- /dev/null +++ b/core/src/events/onHunger.kt @@ -0,0 +1,15 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class HungerEvent(val plugin: Khs, val player: Player) : Event() + +fun onHunger(event: HungerEvent) { + val (plugin, player) = event + val game = plugin.game + + if (!game.hasPlayer(player)) return + + event.cancel() +} diff --git a/core/src/events/onInteract.kt b/core/src/events/onInteract.kt new file mode 100644 index 0000000..197d684 --- /dev/null +++ b/core/src/events/onInteract.kt @@ -0,0 +1,19 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class InteractEvent(val plugin: Khs, val player: Player, val block: String) : Event() + +fun onInteract(event: InteractEvent) { + val (plugin, player, block) = event + val game = plugin.game + + if (!game.hasPlayer(player)) return + + if (plugin.config.blockedInteracts.any { it.lowercase() == block.lowercase() }) { + // this interaction is blocked! + event.cancel() + return + } +} diff --git a/core/src/events/onJoin.kt b/core/src/events/onJoin.kt new file mode 100644 index 0000000..1089cab --- /dev/null +++ b/core/src/events/onJoin.kt @@ -0,0 +1,35 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class JoinEvent(val plugin: Khs, val player: Player) : Event() + +fun onJoin(event: JoinEvent) { + val (plugin, player) = event + val game = plugin.game + + // save name data for user + plugin.database?.upsertName(player.uuid, player.name) + + // uhhhh + if (game.hasPlayer(player)) game.leave(player.uuid) + + if (plugin.config.autoJoin) { + game.join(player.uuid) + return + } + + val worldName = player.world?.name ?: return + if ( + (plugin.config.teleportStraysToExit && worldName == game.map?.worldName) || + ((plugin.config.teleportStraysToExit || plugin.config.mapSaveEnabled) && + worldName == game.map?.gameWorldName) + ) { + // teleport to exit if inside game world(s) + plugin.config.exit?.let { + player.teleport(it) + player.setGameMode(Player.GameMode.ADVENTURE) + } + } +} diff --git a/core/src/events/onJump.kt b/core/src/events/onJump.kt new file mode 100644 index 0000000..d765ec7 --- /dev/null +++ b/core/src/events/onJump.kt @@ -0,0 +1,15 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class JumpEvent(val plugin: Khs, val player: Player) : Event() + +fun onJump(event: JumpEvent) { + val (plugin, player) = event + val game = plugin.game + + if (!game.isSpectator(player)) return + + if (player.allowFlight) player.flying = true +} diff --git a/core/src/events/onKick.kt b/core/src/events/onKick.kt new file mode 100644 index 0000000..69e7eaa --- /dev/null +++ b/core/src/events/onKick.kt @@ -0,0 +1,20 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class KickEvent(val plugin: Khs, val player: Player, val reason: String) : Event() + +fun onKick(event: KickEvent) { + val (plugin, player, reason) = event + + // spectators are allowed to fly + // this also can be triggered by blockhunt + if (reason.lowercase().contains("flying")) { + event.cancel() + return + } + + // handle leave + onLeave(LeaveEvent(plugin, player)) +} diff --git a/core/src/events/onLeave.kt b/core/src/events/onLeave.kt new file mode 100644 index 0000000..d568171 --- /dev/null +++ b/core/src/events/onLeave.kt @@ -0,0 +1,13 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class LeaveEvent(val plugin: Khs, val player: Player) : Event() + +fun onLeave(event: LeaveEvent) { + val (plugin, player) = event + val game = plugin.game + + if (game.hasPlayer(player)) game.leave(player.uuid) +} diff --git a/core/src/events/onMove.kt b/core/src/events/onMove.kt new file mode 100644 index 0000000..55e5fb3 --- /dev/null +++ b/core/src/events/onMove.kt @@ -0,0 +1,21 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player +import cat.freya.khs.world.Position + +data class MoveEvent(val plugin: Khs, val player: Player, val to: Position) : Event() + +fun onMove(event: MoveEvent) { + val (plugin, player, to) = event + val game = plugin.game + + if (!game.hasPlayer(player)) return + + val map = game.map ?: return + if (player.location.worldName != map.gameWorldName) return + + if (player.hasPermission("hs.leavebounds")) return + + if (map.bounds()?.inBounds(to) == false) event.cancel() +} diff --git a/core/src/events/onRegen.kt b/core/src/events/onRegen.kt new file mode 100644 index 0000000..8644e2c --- /dev/null +++ b/core/src/events/onRegen.kt @@ -0,0 +1,17 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class RegenEvent(val plugin: Khs, val player: Player, val natural: Boolean) : Event() + +fun onRegen(event: RegenEvent) { + val (plugin, player, natural) = event + val game = plugin.game + + if (!game.hasPlayer(player)) return + + if (!natural || plugin.config.regenHealth) return + + event.cancel() +} diff --git a/core/src/events/onUse.kt b/core/src/events/onUse.kt new file mode 100644 index 0000000..9e758b8 --- /dev/null +++ b/core/src/events/onUse.kt @@ -0,0 +1,76 @@ +package cat.freya.khs.event + +import cat.freya.khs.Khs +import cat.freya.khs.game.Game +import cat.freya.khs.inv.createTeleportMenu +import cat.freya.khs.player.Player +import cat.freya.khs.world.Item + +data class UseEvent(val plugin: Khs, val player: Player, val item: Item) : Event() + +private fun onUseLobby(event: UseEvent) { + val (plugin, player, item) = event + + // handle leave + if (item.similar(plugin.config.lobby.leaveItem)) { + event.cancel() + plugin.commandGroup.handleCommand(player, listOf("leave")) + } + + // handle start + if (item.similar(plugin.config.lobby.startItem)) { + event.cancel() + plugin.commandGroup.handleCommand(player, listOf("start")) + } +} + +private fun onUseInGame(event: UseEvent) { + val (plugin, player, item) = event + + if (item.similar(plugin.config.glow.item) && plugin.config.glow.enabled) { + event.cancel() + plugin.game.glow.start() + player.inventory.remove(item) + } +} + +private fun onUseSpectator(event: UseEvent) { + val (plugin, player, item) = event + + // toggle flight + if (item.similar(plugin.config.spectatorItems.flight)) { + event.cancel() + + // toggle flying + player.allowFlight = !player.flying + player.flying = player.allowFlight + player.actionBar( + if (player.flying) plugin.locale.spectator.flyingEnabled + else plugin.locale.spectator.flyingDisabled + ) + } + + // view teleport ui + if (item.similar(plugin.config.spectatorItems.teleport)) { + event.cancel() + + val inv = createTeleportMenu(plugin, 0u) ?: return + player.showInventory(inv) + } +} + +// for a right click interaction +fun onUse(event: UseEvent) { + val (plugin, player, _) = event + val game = plugin.game + + if (!game.hasPlayer(player)) return + + when (game.status) { + Game.Status.LOBBY -> onUseLobby(event) + Game.Status.SEEKING -> onUseInGame(event) + else -> {} + } + + if (game.isSpectator(player)) onUseSpectator(event) +} diff --git a/core/src/game/Board.kt b/core/src/game/Board.kt new file mode 100644 index 0000000..90136d3 --- /dev/null +++ b/core/src/game/Board.kt @@ -0,0 +1,159 @@ +package cat.freya.khs.game + +import cat.freya.khs.Khs +import java.util.UUID +import kotlin.math.roundToInt + +const val DISABLED_IDENT = "KHS_DISABLED_FILTER_ME_OUT" + +interface Board { + fun setText(title: String, text: List<String>) + + fun display(uuid: UUID) + + interface Team { + var prefix: String + + // options + var canCollide: Boolean + var nameTagsVisible: Boolean + + // players + var players: Set<UUID> + } + + // seeker/hider display + fun getTeam(name: String): Board.Team +} + +fun updateTeams(plugin: Khs, board: Board) { + val hider = board.getTeam("Hider") + val seeker = board.getTeam("Seeker") + + hider.players = plugin.game.hiderUUIDs + seeker.players = plugin.game.seekerUUIDs + + hider.nameTagsVisible = plugin.config.nametagsVisible + seeker.nameTagsVisible = plugin.config.nametagsVisible + + hider.canCollide = false + seeker.canCollide = false + + hider.prefix = plugin.locale.game.team.hider + seeker.prefix = plugin.locale.game.team.seeker +} + +fun getLobbyBoard(plugin: Khs, uuid: UUID): Board? { + return plugin.shim.getBoard("lobby-$uuid") +} + +fun reloadLobbyBoard(plugin: Khs, uuid: UUID) { + val timer = plugin.game.timer + val countdown = + when { + timer != null -> plugin.boardConfig.countdown.startingIn.with(timer) + else -> plugin.boardConfig.countdown.waiting + } + val count = plugin.game.size + val seekerPercent = (plugin.game.getSeekerChance(uuid) * 100).roundToInt() + val hiderPercent = 100 - seekerPercent + val map = plugin.game.map?.name ?: "" + + val board = getLobbyBoard(plugin, uuid) ?: return + updateTeams(plugin, board) + + val title = plugin.boardConfig.lobby.title + board.setText( + title, + plugin.boardConfig.lobby.content.map { + it.replace("{COUNTDOWN}", countdown) + .replace("{COUNT}", count.toString()) + .replace("{SEEKER%}", seekerPercent.toString()) + .replace("{HIDER%}", hiderPercent.toString()) + .replace("{MAP}", map) + }, + ) + board.display(uuid) +} + +fun getGameBoard(plugin: Khs, uuid: UUID): Board? { + return plugin.shim.getBoard("game-$uuid") +} + +private fun getBorderLocale(plugin: Khs): String { + val config = plugin.game.map?.config?.worldBorder + val border = plugin.game.border + + if (config?.enabled != true || border.expired) return DISABLED_IDENT + + if (border.state == Border.State.SHRINKING) return plugin.boardConfig.border.shrinking + + val m = border.timer / 60UL + val s = border.timer % 60UL + return plugin.boardConfig.border.timer.with(m, s) +} + +private fun getTauntLocale(plugin: Khs): String { + val config = plugin.config.taunt + val taunt = plugin.game.taunt + + if (!config.enabled || taunt.expired) return DISABLED_IDENT + + if (taunt.running) return plugin.boardConfig.taunt.active + + val m = taunt.timer / 60UL + val s = taunt.timer % 60UL + return plugin.boardConfig.taunt.timer.with(m, s) +} + +private fun getGlowLocale(plugin: Khs): String { + val config = plugin.config.glow + val always = plugin.config.alwaysGlow + val glow = plugin.game.glow + + if (always || !config.enabled) return DISABLED_IDENT + + if (glow.running) return plugin.boardConfig.glow.active + else return plugin.boardConfig.glow.disabled +} + +fun reloadGameBoard(plugin: Khs, uuid: UUID) { + val timer = plugin.game.timer + + val time = plugin.boardConfig.countdown.timer.with((timer ?: 0UL) / 60UL, (timer ?: 0UL) % 60UL) + val team = + when (plugin.game.getTeam(uuid)) { + Game.Team.HIDER -> plugin.locale.game.team.hider + Game.Team.SEEKER -> plugin.locale.game.team.seeker + else -> plugin.locale.game.team.spectator + } + + // border event + val border = getBorderLocale(plugin) + val taunt = getTauntLocale(plugin) + val glow = getGlowLocale(plugin) + val numSeeker = plugin.game.seekerSize + val numHider = plugin.game.hiderSize + val map = plugin.game.map?.name ?: "" + + val board = getGameBoard(plugin, uuid) ?: return + updateTeams(plugin, board) + + val title = plugin.boardConfig.game.title + board.setText( + title, + plugin.boardConfig.game.content + .map { + it.replace("{TIME}", time) + .replace("{TEAM}", team) + .replace("{BORDER}", border) + .replace("{TAUNT}", taunt) + .replace("{GLOW}", glow) + .replace("{#SEEKER}", numSeeker.toString()) + .replace("{#HIDER}", numHider.toString()) + .replace("{MAP}", map) + } + .filter { !it.contains(DISABLED_IDENT) }, + ) + board.display(uuid) +} diff --git a/core/src/game/Border.kt b/core/src/game/Border.kt new file mode 100644 index 0000000..f535681 --- /dev/null +++ b/core/src/game/Border.kt @@ -0,0 +1,78 @@ +package cat.freya.khs.game + +import cat.freya.khs.config.WorldBorderConfig +import cat.freya.khs.world.World + +class Border(val game: Game) { + + enum class State { + WAITING, + WARNED, + SHRINKING, + } + + @Volatile var timer: ULong = 0UL + + @Volatile var state: State = State.WAITING + + @Volatile private var enabled: Boolean = false + + private val border: World.Border? + get() = game.map?.gameWorld?.border + + private val borderConfig: WorldBorderConfig? + get() = game.map?.config?.worldBorder + + val expired: Boolean + get() = border?.size?.let { it <= 100.0 } != true + + fun reset() { + enabled = false + state = State.WAITING + + val border = border ?: return + val borderConfig = borderConfig ?: return + + val x = borderConfig.pos?.x ?: return + val z = borderConfig.pos?.z ?: return + val size = borderConfig.size ?: return + + border.move(x, z, size, 0UL) + } + + fun update() { + if (borderConfig?.enabled != true) return + + if (timer != 0UL) { + timer-- + return + } + + if (state == State.WARNED) { + // start the world border movement! + var amount = borderConfig?.move ?: return + val currentSize = border?.size?.toULong() ?: return + + if (amount >= currentSize) return + + if (amount - 100UL <= currentSize) amount = 100UL + + timer = 30UL + state = State.SHRINKING + + border?.move(amount, timer) + game.broadcast(game.plugin.locale.worldBorder.shrinking) + return + } + + if (state == State.SHRINKING) { + timer = borderConfig?.delay ?: return + state = State.WAITING + return + } + + game.broadcast(game.plugin.locale.prefix.border + game.plugin.locale.worldBorder.warn) + timer = 30UL + state = State.WARNED + } +} diff --git a/core/src/game/Game.kt b/core/src/game/Game.kt new file mode 100644 index 0000000..70187e3 --- /dev/null +++ b/core/src/game/Game.kt @@ -0,0 +1,757 @@ +package cat.freya.khs.game + +import cat.freya.khs.Khs +import cat.freya.khs.config.ConfigCountdownDisplay +import cat.freya.khs.config.ConfigLeaveType +import cat.freya.khs.config.ConfigScoringMode +import cat.freya.khs.inv.createBlockHuntPicker +import cat.freya.khs.player.Player +import cat.freya.khs.world.Item +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.min +import kotlin.math.round +import kotlin.random.Random +import kotlin.synchronized + +class Game(val plugin: Khs) { + /// represents what state the game is in + enum class Status { + LOBBY, + HIDING, + SEEKING, + FINISHED; + + fun inProgress(): Boolean = + when (this) { + LOBBY -> false + HIDING -> true + SEEKING -> true + FINISHED -> false + } + } + + /// what team a player is on + enum class Team { + HIDER, + SEEKER, + SPECTATOR, + } + + /// why was the game stopped? + enum class WinType { + NONE, + SEEKER_WIN, + HIDER_WIN, + } + + @Volatile + /// the state the game is in + var status: Status = Status.LOBBY + private set + + @Volatile + /// timer for current game status (lobby, hiding, seeking, finished) + var timer: ULong? = null + private set + + @Volatile + /// keep track till next second + private var gameTick: UInt = 0u + private val isSecond: Boolean + get() = gameTick % 20u == 0u + + @Volatile + /// if the last event was a hider leaving the game + private var hiderLeft: Boolean = false + + @Volatile + /// the current game round + private var round: Int = 0 + + val glow: Glow = Glow(this) + val taunt: Taunt = Taunt(this) + val border: Border = Border(this) + + @Volatile + var map: KhsMap? = null + private set + + private val mappings: MutableMap<UUID, Team> = ConcurrentHashMap<UUID, Team>() + + val players: List<Player> + get() = mappings.keys.map { plugin.shim.getPlayer(it) }.filterNotNull() + + val UUIDs: Set<UUID> + get() = mappings.keys.toSet() + + val hiderUUIDs: Set<UUID> + get() = mappings.filter { it.value == Team.HIDER }.keys + + val hiderPlayers: List<Player> + get() = hiderUUIDs.map { plugin.shim.getPlayer(it) }.filterNotNull() + + val seekerUUIDs: Set<UUID> + get() = mappings.filter { it.value == Team.SEEKER }.keys + + val seekerPlayers: List<Player> + get() = seekerUUIDs.map { plugin.shim.getPlayer(it) }.filterNotNull() + + val spectatorUUIDs: Set<UUID> + get() = mappings.filter { it.value == Team.SPECTATOR }.keys + + val spectatorPlayers: List<Player> + get() = spectatorUUIDs.map { plugin.shim.getPlayer(it) }.filterNotNull() + + val size: UInt + get() = mappings.size.toUInt() + + val hiderSize: UInt + get() = hiderUUIDs.size.toUInt() + + val seekerSize: UInt + get() = seekerUUIDs.size.toUInt() + + val spectatorsSize: UInt + get() = spectatorUUIDs.size.toUInt() + + fun hasPlayer(uuid: UUID): Boolean = mappings.containsKey(uuid) + + fun hasPlayer(player: Player): Boolean = hasPlayer(player.uuid) + + fun isHider(uuid: UUID): Boolean = mappings[uuid] == Team.HIDER + + fun isHider(player: Player): Boolean = isHider(player.uuid) + + fun isSeeker(uuid: UUID): Boolean = mappings[uuid] == Team.SEEKER + + fun isSeeker(player: Player): Boolean = isSeeker(player.uuid) + + fun isSpectator(uuid: UUID): Boolean = mappings[uuid] == Team.SPECTATOR + + fun isSpectator(player: Player): Boolean = isSpectator(player.uuid) + + fun getTeam(uuid: UUID): Team? = mappings.get(uuid) + + fun setTeam(uuid: UUID, team: Team) { + mappings[uuid] = team + } + + fun sameTeam(a: UUID, b: UUID): Boolean = mappings[a] == mappings[b] + + // what round was the uuid last picked to be seeker + private val lastPicked: MutableMap<UUID, Int> = ConcurrentHashMap<UUID, Int>() + + @Volatile + // teams at the start of the game + private var initialTeams: Map<UUID, Team> = emptyMap() + + @Volatile + // stores saved inventories + private var savedInventories: MutableMap<UUID, List<Item?>> = + ConcurrentHashMap<UUID, List<Item?>>() + + // status for this round + private var hiderKills: MutableMap<UUID, UInt> = ConcurrentHashMap<UUID, UInt>() + private var seekerKills: MutableMap<UUID, UInt> = ConcurrentHashMap<UUID, UInt>() + private var hiderDeaths: MutableMap<UUID, UInt> = ConcurrentHashMap<UUID, UInt>() + private var seekerDeaths: MutableMap<UUID, UInt> = ConcurrentHashMap<UUID, UInt>() + + fun doTick() { + if (map?.setup != true) return + + when (status) { + Status.LOBBY -> whileWaiting() + Status.HIDING -> whileHiding() + Status.SEEKING -> whileSeeking() + Status.FINISHED -> whileFinished() + } + + gameTick++ + } + + fun selectMap(): KhsMap? { + map = map ?: plugin.maps.values.filter { it.setup }.randomOrNull() + return map + } + + fun setMap(map: KhsMap?) { + if (status != Status.LOBBY) return + + if (map == null && size > 0u) return + + this.map = map + players.forEach { player -> joinPlayer(player) } + } + + fun getSeekerWeight(uuid: UUID): Double { + val last = lastPicked[uuid] ?: -1000 + val weight = (round - last).toDouble() + return weight + } + + fun getSeekerChance(uuid: UUID): Double { + val weights = mappings.keys.map { getSeekerWeight(it) } + val totalWeight = weights.sum() + val weight = getSeekerWeight(uuid) + if (totalWeight == 0.0) return 0.0 + return weight / totalWeight + } + + private fun randomSeeker(pool: Set<UUID>): UUID { + val weights = pool.map { uuid -> uuid to getSeekerWeight(uuid) } + + val totalWeight = weights.sumOf { it.second } + var r = Random.nextDouble() * totalWeight + + for ((uuid, weight) in weights) { + r -= weight + if (r <= 0) { + lastPicked[uuid] = round + return uuid + } + } + + return pool.random() + } + + fun start() { + start(emptySet()) + } + + fun start(requestedPool: Collection<UUID>) { + val seekers = mutableSetOf<UUID>() + val pool = if (requestedPool.isEmpty()) mappings.keys else requestedPool.toMutableSet() + + while (pool.size >= 2 && seekers.size.toUInt() < plugin.config.startingSeekerCount) { + val uuid = randomSeeker(pool) + pool.remove(uuid) + seekers.add(uuid) + } + + if (seekers.isEmpty()) // warning here? + return + + startWithSeekers(seekers) + } + + private fun startWithSeekers(seekers: Set<UUID>) { + if (status != Status.LOBBY) return + + if (plugin.config.mapSaveEnabled) map?.loader?.rollback() + + synchronized(this) { + // set teams + mappings.forEach { mappings[it.key] = Team.HIDER } + seekers.forEach { mappings[it] = Team.SEEKER } + + // reset game state + initialTeams = mappings.toMap() + hiderKills.clear() + seekerKills.clear() + hiderDeaths.clear() + seekerDeaths.clear() + + // give items + loadHiders() + loadSeekers() + + // reload sidebar + reloadGameBoards() + + glow.reset() + taunt.reset() + border.reset() + + status = Status.HIDING + timer = null + } + } + + private fun updatePlayerInfo(uuid: UUID, reason: WinType) { + val team = initialTeams.get(uuid) ?: return + val data = plugin.database?.getPlayer(uuid) ?: return + + when (reason) { + WinType.SEEKER_WIN -> { + if (team == Team.SEEKER) data.seekerWins++ + if (team == Team.HIDER) data.hiderLosses++ + } + WinType.HIDER_WIN -> { + if (team == Team.SEEKER) data.seekerLosses++ + if (team == Team.HIDER) data.hiderWins++ + } + WinType.NONE -> {} + } + + data.seekerKills += seekerKills.getOrDefault(uuid, 0u) + data.hiderKills += hiderKills.getOrDefault(uuid, 0u) + data.seekerDeaths += seekerDeaths.getOrDefault(uuid, 0u) + data.hiderDeaths += hiderDeaths.getOrDefault(uuid, 0u) + + plugin.database?.upsertPlayer(data) + } + + fun stop(reason: WinType) { + if (!status.inProgress()) return + + // update database + mappings.keys.forEach { updatePlayerInfo(it, reason) } + + round++ + status = Status.FINISHED + timer = null + + if (plugin.config.leaveOnEnd) { + mappings.keys.forEach { leave(it) } + } + } + + fun join(uuid: UUID) { + val player = plugin.shim.getPlayer(uuid) ?: return + + if (map == null) selectMap() + + if (map == null) { + player.message(plugin.locale.prefix.error + plugin.locale.map.none) + return + } + + if (status != Status.LOBBY) { + mappings[uuid] = Team.SPECTATOR + loadSpectator(player) + reloadGameBoards() + player.message(plugin.locale.prefix.default + plugin.locale.game.join) + return + } + + if (plugin.config.saveInventory) savedInventories[uuid] = player.inventory.contents + + mappings[uuid] = Team.HIDER + joinPlayer(player) + reloadLobbyBoards() + + broadcast(plugin.locale.prefix.default + plugin.locale.lobby.join.with(player.name)) + } + + fun leave(uuid: UUID) { + val player = plugin.shim.getPlayer(uuid) ?: return + + broadcast(plugin.locale.prefix.default + plugin.locale.game.leave.with(player.name)) + + mappings.remove(uuid) + resetPlayer(player) + + if (plugin.config.saveInventory) + savedInventories.get(uuid)?.let { player.inventory.contents = it } + + // reload sidebar + player.hideBoards() + if (status.inProgress()) { + reloadGameBoards() + } else { + reloadLobbyBoards() + } + + when (plugin.config.leaveType) { + ConfigLeaveType.EXIT -> plugin.config.exit?.let { player.teleport(it) } + ConfigLeaveType.PROXY -> player.sendToServer(plugin.config.leaveServer) + } + } + + fun addKill(uuid: UUID) { + val team = mappings.get(uuid) ?: return + when (team) { + Team.HIDER -> hiderKills[uuid] = hiderKills.getOrDefault(uuid, 0u) + 1u + Team.SEEKER -> seekerKills[uuid] = seekerKills.getOrDefault(uuid, 0u) + 1u + else -> {} + } + } + + fun addDeath(uuid: UUID) { + val team = mappings.get(uuid) ?: return + when (team) { + Team.HIDER -> hiderDeaths[uuid] = hiderDeaths.getOrDefault(uuid, 0u) + 1u + Team.SEEKER -> seekerDeaths[uuid] = seekerDeaths.getOrDefault(uuid, 0u) + 1u + else -> {} + } + } + + private fun reloadLobbyBoards() { + mappings.keys.forEach { reloadLobbyBoard(plugin, it) } + } + + private fun reloadGameBoards() { + mappings.keys.forEach { reloadGameBoard(plugin, it) } + } + + /// during Status.LOBBY + private fun whileWaiting() { + val countdown = plugin.config.lobby.countdown + val changeCountdown = plugin.config.lobby.changeCountdown + + synchronized(this) { + // countdown is disabled when set to at 0s + if (countdown == 0UL || size < plugin.config.lobby.min) { + timer = null + return@synchronized + } + + var time = timer ?: countdown + if (size >= changeCountdown && changeCountdown != 0u) time = min(time, 10UL) + if (isSecond && time > 0UL) time-- + timer = time + } + + if (isSecond) reloadLobbyBoards() + + if (timer == 0UL) start() + } + + /// during Status.HIDING + private fun whileHiding() { + if (!isSecond) return + + if (timer != 0UL) checkWinConditions() + + if (isSecond) reloadGameBoards() + + val time: ULong + val message: String + synchronized(this) { + time = timer ?: plugin.config.hidingLength + when (time) { + 0UL -> { + message = plugin.locale.game.start + status = Status.SEEKING + timer = null + seekerPlayers.forEach { + giveSeekerItems(it) + map?.gameSpawn?.teleport(it) + } + hiderPlayers.forEach { giveHiderItems(it) } + } + 1UL -> message = plugin.locale.game.countdown.last + else -> message = plugin.locale.game.countdown.notify.with(time) + } + + if (status == Status.HIDING) timer = if (time > 0UL) (time - 1UL) else time + } + + if (time % 5UL == 0UL || time <= 5UL) { + val prefix = plugin.locale.prefix.default + players.forEach { player -> + when (plugin.config.countdownDisplay) { + ConfigCountdownDisplay.CHAT -> player.message(prefix + message) + ConfigCountdownDisplay.ACTIONBAR -> player.actionBar(prefix + message) + ConfigCountdownDisplay.TITLE -> { + if (time != 30UL) player.title(" ", message) + } + } + } + } + } + + /// @returns distnace to closest seeker to the player + private fun distanceToSeeker(player: Player): Double { + return seekerPlayers.map { seeker -> player.location.distance(seeker.location) }.minOrNull() + ?: Double.POSITIVE_INFINITY + } + + /// plays the seeker ping for a hider + private fun playSeekerPing(hider: Player) { + val distance = distanceToSeeker(hider) + + // read config values + val distances = plugin.config.seekerPing.distances + val sounds = plugin.config.seekerPing.sounds + + when (gameTick % 10u) { + 0u -> { + if (distance < distances.level1.toDouble()) + hider.playSound(sounds.heartbeatNoise, sounds.leadingVolume, sounds.pitch) + if (distance < distances.level3.toDouble()) + hider.playSound(sounds.ringingNoise, sounds.volume, sounds.pitch) + } + 3u -> { + if (distance < distances.level1.toDouble()) + hider.playSound(sounds.heartbeatNoise, sounds.volume, sounds.pitch) + if (distance < distances.level3.toDouble()) + hider.playSound(sounds.ringingNoise, sounds.volume, sounds.pitch) + } + 6u -> { + if (distance < distances.level3.toDouble()) + hider.playSound(sounds.ringingNoise, sounds.volume, sounds.pitch) + } + 9u -> { + if (distance < distances.level2.toDouble()) + hider.playSound(sounds.ringingNoise, sounds.volume, sounds.pitch) + } + } + } + + private fun checkWinConditions() { + var stopReason: WinType? = null + + val scoreMode = plugin.config.scoringMode + val notEnoughHiders = + when (scoreMode) { + ConfigScoringMode.ALL_HIDERS_FOUND -> hiderSize == 0u + ConfigScoringMode.LAST_HIDER_WINS -> hiderSize == 1u + } + val lastHider = hiderPlayers.firstOrNull() + + val doTitle = plugin.config.gameOverTitle + val prefix = plugin.locale.prefix + + when { + // time ran out + timer == 0UL -> { + broadcast(prefix.gameOver + plugin.locale.game.gameOver.time) + if (doTitle) + broadcastTitle( + plugin.locale.game.title.hidersWin, + plugin.locale.game.gameOver.time, + ) + stopReason = WinType.HIDER_WIN + } + // all seekers quit + seekerSize < 1u -> { + broadcast(prefix.abort + plugin.locale.game.gameOver.seekerQuit) + if (doTitle) + broadcastTitle( + plugin.locale.game.title.noWin, + plugin.locale.game.gameOver.seekerQuit, + ) + stopReason = if (plugin.config.dontRewardQuit) WinType.NONE else WinType.HIDER_WIN + } + // hiders quit + notEnoughHiders && hiderLeft -> { + broadcast(prefix.abort + plugin.locale.game.gameOver.hiderQuit) + if (doTitle) + broadcastTitle( + plugin.locale.game.title.noWin, + plugin.locale.game.gameOver.hiderQuit, + ) + stopReason = if (plugin.config.dontRewardQuit) WinType.NONE else WinType.SEEKER_WIN + } + // all hiders found + notEnoughHiders && lastHider == null -> { + broadcast(prefix.gameOver + plugin.locale.game.gameOver.hidersFound) + if (doTitle) + broadcastTitle( + plugin.locale.game.title.seekersWin, + plugin.locale.game.gameOver.hidersFound, + ) + stopReason = WinType.SEEKER_WIN + } + // last hider wins (depends on scoring more) + notEnoughHiders && lastHider != null -> { + val msg = plugin.locale.game.gameOver.lastHider.with(lastHider.name) + broadcast(prefix.gameOver + msg) + if (doTitle) + broadcastTitle( + plugin.locale.game.title.singleHiderWin.with(lastHider.name), + msg, + ) + stopReason = WinType.HIDER_WIN + } + } + + if (stopReason != null) stop(stopReason) + + hiderLeft = false + } + + /// during Status.SEEKING + private fun whileSeeking() { + if (plugin.config.seekerPing.enabled) hiderPlayers.forEach { playSeekerPing(it) } + + synchronized(this) { + var time = timer + if (time == null && plugin.config.gameLength != 0UL) time = plugin.config.gameLength + + if (isSecond) { + if (time != null && time > 0UL) time-- + + taunt.update() + glow.update() + border.update() + } + + timer = time + } + + if (isSecond) reloadGameBoards() + + // update spectator flight + // (the toggle they have only changed allowed flight) + spectatorPlayers.forEach { it.flying = it.allowFlight } + + checkWinConditions() + } + + /// during Status.FINISHED + private fun whileFinished() { + synchronized(this) { + var time = timer ?: plugin.config.endGameDelay + if (time > 0UL) time-- + + timer = time + + if (time == 0UL) { + timer = null + map = null + selectMap() + + if (map == null) { + broadcast(plugin.locale.prefix.warning + plugin.locale.map.none) + return + } + + status = Status.LOBBY + + players.forEach { joinPlayer(it) } + } + } + } + + fun broadcast(message: String) { + players.forEach { it.message(message) } + } + + fun broadcastTitle(title: String, subTitle: String) { + players.forEach { it.title(title, subTitle) } + } + + private fun loadHiders() = hiderPlayers.forEach { loadHider(it) } + + private fun loadSeekers() = seekerPlayers.forEach { loadSeeker(it) } + + private fun hidePlayer(player: Player, hidden: Boolean) { + players.forEach { other -> if (other.uuid != player.uuid) other.setHidden(player, hidden) } + } + + fun resetPlayer(player: Player) { + player.flying = false + player.allowFlight = false + player.setGameMode(Player.GameMode.ADVENTURE) + player.inventory.clear() + player.clearEffects() + player.hunger = 20u + player.heal() + player.revealDisguise() + hidePlayer(player, false) + } + + fun loadHider(hider: Player) { + map?.gameSpawn?.teleport(hider) + resetPlayer(hider) + hider.setSpeed(5u) + hider.title(plugin.locale.game.team.hider, plugin.locale.game.team.hiderSubtitle) + + // open block hunt picker + if (map?.config?.blockHunt?.enabled == true) { + val map = map ?: return + val inv = createBlockHuntPicker(plugin, map) ?: return + hider.showInventory(inv) + } + } + + fun giveHiderItems(hider: Player) { + var items = plugin.itemsConfig.hiderItems.map { plugin.shim.parseItem(it) }.filterNotNull() + var effects = + plugin.itemsConfig.hiderEffects.map { plugin.shim.parseEffect(it) }.filterNotNull() + + hider.inventory.clear() + for ((i, item) in items.withIndex()) hider.inventory.set(i.toUInt(), item) + + // glow powerup + if (!plugin.config.alwaysGlow && plugin.config.glow.enabled) { + val item = plugin.shim.parseItem(plugin.config.glow.item) + item?.let { hider.inventory.set(items.size.toUInt(), it) } + } + + plugin.itemsConfig.hiderHelmet + ?.let { plugin.shim.parseItem(it) } + ?.let { hider.inventory.helmet = it } + plugin.itemsConfig.hiderChestplate + ?.let { plugin.shim.parseItem(it) } + ?.let { hider.inventory.chestplate = it } + plugin.itemsConfig.hiderLeggings + ?.let { plugin.shim.parseItem(it) } + ?.let { hider.inventory.leggings = it } + plugin.itemsConfig.hiderBoots + ?.let { plugin.shim.parseItem(it) } + ?.let { hider.inventory.boots = it } + + hider.clearEffects() + for (effect in effects) hider.giveEffect(effect) + } + + fun loadSeeker(seeker: Player) { + map?.seekerLobbySpawn?.teleport(seeker) + resetPlayer(seeker) + seeker.title(plugin.locale.game.team.seeker, plugin.locale.game.team.seekerSubtitle) + } + + fun giveSeekerItems(seeker: Player) { + var items = plugin.itemsConfig.seekerItems.map { plugin.shim.parseItem(it) }.filterNotNull() + var effects = + plugin.itemsConfig.seekerEffects.map { plugin.shim.parseEffect(it) }.filterNotNull() + + seeker.inventory.clear() + for ((i, item) in items.withIndex()) seeker.inventory.set(i.toUInt(), item) + + plugin.itemsConfig.seekerHelmet + ?.let { plugin.shim.parseItem(it) } + ?.let { seeker.inventory.helmet = it } + plugin.itemsConfig.seekerChestplate + ?.let { plugin.shim.parseItem(it) } + ?.let { seeker.inventory.chestplate = it } + plugin.itemsConfig.seekerLeggings + ?.let { plugin.shim.parseItem(it) } + ?.let { seeker.inventory.leggings = it } + plugin.itemsConfig.seekerBoots + ?.let { plugin.shim.parseItem(it) } + ?.let { seeker.inventory.boots = it } + + seeker.clearEffects() + for (effect in effects) seeker.giveEffect(effect) + } + + fun loadSpectator(spectator: Player) { + map?.gameSpawn?.teleport(spectator) + resetPlayer(spectator) + spectator.allowFlight = true + spectator.flying = true + + plugin.config.spectatorItems.teleport + .let { plugin.shim.parseItem(it) } + ?.let { spectator.inventory.set(3u, it) } + + plugin.config.spectatorItems.flight + .let { plugin.shim.parseItem(it) } + ?.let { spectator.inventory.set(6u, it) } + + hidePlayer(spectator, true) + } + + private fun joinPlayer(player: Player) { + map?.lobbySpawn?.teleport(player) + resetPlayer(player) + + plugin.config.lobby.leaveItem + .let { plugin.shim.parseItem(it) } + ?.let { player.inventory.set(0u, it) } + + if (player.hasPermission("hs.start")) { + plugin.config.lobby.startItem + .let { plugin.shim.parseItem(it) } + ?.let { player.inventory.set(8u, it) } + } + + if (getTeam(player.uuid) != Team.SPECTATOR) + spectatorPlayers.forEach { player.setHidden(it, true) } + } +} diff --git a/core/src/game/Glow.kt b/core/src/game/Glow.kt new file mode 100644 index 0000000..a2289ba --- /dev/null +++ b/core/src/game/Glow.kt @@ -0,0 +1,48 @@ +package cat.freya.khs.game + +class Glow(val game: Game) { + + @Volatile var timer: ULong = 0UL + @Volatile var running: Boolean = true + + fun start() { + running = true + if (game.plugin.config.glow.stackable) { + timer += game.plugin.config.glow.time + } else { + timer = game.plugin.config.glow.time + } + } + + fun reset() { + running = false + timer = 0UL + } + + private fun sendPackets(glow: Boolean) { + for (hider in game.hiderPlayers) for (seeker in game.seekerPlayers) hider.setGlow( + seeker, + glow, + ) + } + + fun update() { + if (!game.plugin.config.glow.enabled) return + + if (game.plugin.config.alwaysGlow) { + sendPackets(true) + return + } + + if (!running) return + + if (timer >= 0UL) timer-- + + if (timer == 0UL) { + running = false + sendPackets(false) + } else { + sendPackets(true) + } + } +} diff --git a/core/src/game/Map.kt b/core/src/game/Map.kt new file mode 100644 index 0000000..30b7c8b --- /dev/null +++ b/core/src/game/Map.kt @@ -0,0 +1,69 @@ +package cat.freya.khs.game + +import cat.freya.khs.Khs +import cat.freya.khs.config.MapConfig +import cat.freya.khs.world.Location +import cat.freya.khs.world.Position +import cat.freya.khs.world.World + +class KhsMap(val name: String, var config: MapConfig, var plugin: Khs) { + + var worldName: String = "null" + var gameWorldName: String = "null" + + var gameSpawn: Location? = null + var lobbySpawn: Location? = null + var seekerLobbySpawn: Location? = null + + val world: World? + get() = plugin.shim.getWorld(worldName) + + val gameWorld: World? + get() = plugin.shim.getWorld(gameWorldName) + + val loader: World.Loader + get() = plugin.shim.getWorldLoader(gameWorldName) + + data class Bounds(val minX: Double, val minZ: Double, val maxX: Double, val maxZ: Double) { + fun inBounds(x: Double, z: Double): Boolean = + (x >= minX) || (x >= minZ) || (z <= maxX) || (z <= maxZ) + + fun inBounds(pos: Position): Boolean = inBounds(pos.x, pos.y) + } + + init { + reloadConfig() + } + + fun reloadConfig() { + worldName = config.world ?: error("map '$name' has no world set!") + gameWorldName = if (plugin.config.mapSaveEnabled) "hs_$worldName" else worldName + gameSpawn = config.spawns.game?.toPosition()?.withWorld(gameWorldName) + lobbySpawn = config.spawns.lobby?.withWorld(worldName) + seekerLobbySpawn = config.spawns.seeker?.withWorld(gameWorldName) + } + + fun bounds(): Bounds? { + val minX = config.bounds.min?.x ?: return null + val minZ = config.bounds.min?.z ?: return null + val maxX = config.bounds.max?.x ?: return null + val maxZ = config.bounds.max?.z ?: return null + + return Bounds(minX, minZ, maxX, maxZ) + } + + fun hasMapSave(): Boolean { + val loader = plugin.shim.getWorldLoader(worldName) + return loader.saveDir.exists() + } + + val setup: Boolean + get() = + (gameSpawn != null) && + (lobbySpawn != null) && + (seekerLobbySpawn != null) && + (plugin.config.exit != null) && + (bounds() != null) && + (hasMapSave() || !plugin.config.mapSaveEnabled) && + (!config.blockHunt.enabled || !config.blockHunt.blocks.isEmpty()) +} diff --git a/core/src/game/MapSave.kt b/core/src/game/MapSave.kt new file mode 100644 index 0000000..bead05b --- /dev/null +++ b/core/src/game/MapSave.kt @@ -0,0 +1,126 @@ +package cat.freya.khs.game + +import cat.freya.khs.Khs +import cat.freya.khs.world.World +import java.io.File +import kotlin.error +import kotlin.io.deleteRecursively + +private fun copyWorldFolder( + plugin: Khs, + map: KhsMap, + loader: World.Loader, + name: String, + isMca: Boolean, +): Boolean { + val dir = loader.dir + val temp = loader.tempSaveDir + + val bounds = map.bounds() ?: return false + + val region = File(dir, name) + val tempRegion = File(temp, name) + + if (!tempRegion.exists() && tempRegion.mkdirs() == false) { + plugin.shim.logger.error("could not create directory: ${tempRegion.getPath()}") + return false + } + + val files = region.list() + if (files == null) { + plugin.shim.logger.error("could not access directory: ${region.getPath()}") + return false + } + + for (fileName in files) { + val parts = fileName.split("\\.") + if (isMca && parts.size > 1) { + if ( + (parts[1].toInt() < bounds.minX / 512) || + (parts[1].toInt() > bounds.maxX / 512) || + (parts[2].toInt() < bounds.minZ / 512) || + (parts[2].toInt() > bounds.maxZ / 512) + ) + continue + } + + val srcFile = File(region, fileName) + if (srcFile.isDirectory()) { + copyWorldFolder(plugin, map, loader, name + File.separator + fileName, false) + } else { + val destFile = File(tempRegion, fileName) + srcFile.copyTo(destFile, overwrite = true) + } + } + + return true +} + +private fun copyWorldFile(loader: World.Loader, name: String) { + val dir = loader.dir + val temp = loader.tempSaveDir + + val srcFile = File(dir, name) + val destFile = File(temp, name) + + srcFile.copyTo(destFile, overwrite = true) +} + +fun mapSave(plugin: Khs, map: KhsMap): Result<Unit> = + runCatching { + plugin.shim.logger.info("starting map save for: ${map.worldName}") + plugin.saving = true + + plugin.shim.broadcast(plugin.locale.prefix.default + plugin.locale.map.save.start) + plugin.shim.broadcast(plugin.locale.prefix.warning + plugin.locale.map.save.warning) + + if (plugin.config.mapSaveEnabled == false) error("map saves are disabled!") + + val loader = plugin.shim.getWorldLoader(map.worldName) + val mapSaveLoader = plugin.shim.getWorldLoader(map.gameWorldName) + val dir = loader.dir + + if (!dir.exists()) { + plugin.shim.broadcast( + plugin.locale.prefix.error + plugin.locale.map.save.failedLocate + ) + error("there is no map to save") + } + + mapSaveLoader.unload() + + copyWorldFolder(plugin, map, loader, "region", true) + copyWorldFolder(plugin, map, loader, "entities", true) + copyWorldFolder(plugin, map, loader, "datapacks", false) + copyWorldFolder(plugin, map, loader, "data", false) + copyWorldFile(loader, "level.dat") + + val dest = mapSaveLoader.dir + if (dest.exists() && !dest.deleteRecursively()) { + plugin.shim.broadcast( + plugin.locale.prefix.error + + plugin.locale.map.save.failedDir.with(dest.toPath()) + ) + error("could not delete destination directory") + } + + val tempDest = loader.tempSaveDir + if (!tempDest.renameTo(dest)) { + plugin.shim.broadcast( + plugin.locale.prefix.error + + plugin.locale.map.save.failedDir.with(tempDest.toPath()) + ) + error("could not rename: ${tempDest.toPath()}") + } + } + .onSuccess { + plugin.saving = false + plugin.shim.broadcast(plugin.locale.prefix.default + plugin.locale.map.save.finished) + } + .onFailure { + plugin.saving = false + plugin.shim.broadcast( + plugin.locale.prefix.error + + plugin.locale.map.save.failed.with(it.message ?: "unknown error") + ) + } diff --git a/core/src/game/Taunt.kt b/core/src/game/Taunt.kt new file mode 100644 index 0000000..3a7d707 --- /dev/null +++ b/core/src/game/Taunt.kt @@ -0,0 +1,55 @@ +package cat.freya.khs.game + +import java.util.UUID + +class Taunt(val game: Game) { + + @Volatile var timer: ULong = 0UL + @Volatile var running: Boolean = true + @Volatile var taunted: UUID? = null + @Volatile var last: UUID? = null + + val expired: Boolean + get() = game.hiderSize <= 1UL + + fun reset() { + running = false + timer = game.plugin.config.taunt.delay + last = taunted + taunted = null + } + + fun update() { + if (!game.plugin.config.taunt.enabled || expired) return + + if (timer != 0UL) { + timer-- + return + } + + // running means we are to taunt! + if (running) { + // if player left, well, damn + if (taunted?.let { game.hasPlayer(it) } != true) { + reset() + return + } + + val player = taunted?.let { game.plugin.shim.getPlayer(it) } + player?.taunt() + + game.broadcast(game.plugin.locale.prefix.taunt + game.plugin.locale.taunt.activate) + reset() + return + } + + // select a hider to taunt + val hider = game.hiderPlayers.filter { it.uuid != last }.randomOrNull() ?: return + + game.broadcast(game.plugin.locale.prefix.taunt + game.plugin.locale.taunt.warning) + hider.message(game.plugin.locale.taunt.chosen) + timer = 30UL + running = true + taunted = hider.uuid + } +} diff --git a/core/src/inv/BlockHunt.kt b/core/src/inv/BlockHunt.kt new file mode 100644 index 0000000..615c746 --- /dev/null +++ b/core/src/inv/BlockHunt.kt @@ -0,0 +1,26 @@ +package cat.freya.khs.inv + +import cat.freya.khs.Khs +import cat.freya.khs.config.ItemConfig +import cat.freya.khs.game.KhsMap +import cat.freya.khs.player.Inventory + +const val BLOCKHUNT_TITLE_PREFIX = "Select a Block: " + +fun createBlockHuntPicker(plugin: Khs, map: KhsMap): Inventory? { + val blocks = map.config.blockHunt.blocks + + // make inv + val rows = (blocks.size.toUInt() + 8u) / 9u + val size = minOf(rows * 9u, 9u) + val inv = plugin.shim.createInventory("$BLOCKHUNT_TITLE_PREFIX${map.name}", size) ?: return null + + // add items + blocks + .map { plugin.shim.parseItem(ItemConfig(material = it)) } + .filterNotNull() + .withIndex() + .forEach { (i, item) -> inv.set(i.toUInt(), item) } + + return inv +} diff --git a/core/src/inv/Debug.kt b/core/src/inv/Debug.kt new file mode 100644 index 0000000..5e9d9d4 --- /dev/null +++ b/core/src/inv/Debug.kt @@ -0,0 +1,49 @@ +package cat.freya.khs.inv + +import cat.freya.khs.Khs +import cat.freya.khs.config.ItemConfig +import cat.freya.khs.game.Game +import cat.freya.khs.player.Inventory +import cat.freya.khs.player.Player + +const val DEBUG_TITLE = "Teleport" +val BECOME_HIDER = ItemConfig("&6Become a &lHider", "LEATHER_CHESTPLATE") +val BECOME_SEEKER = ItemConfig("&cBecome a &lSEEKER", "GOLDEN_CHESTPLATE") +val BECOME_SPECTATOR = ItemConfig("&8Become a &lSPECTATOR", "IRON_CHESTPLATE") +val DIE_IN_GAME = ItemConfig("&cDie in game", "SKELETON_SKULL") +val REVEAL_DISGUISE = ItemConfig("&cReveal disguise", "BARRIER") + +fun becomeHider(plugin: Khs, player: Player) { + plugin.game.setTeam(player.uuid, Game.Team.HIDER) + plugin.game.loadHider(player) + if (plugin.game.status == Game.Status.SEEKING) plugin.game.giveHiderItems(player) +} + +fun becomeSeeker(plugin: Khs, player: Player) { + plugin.game.setTeam(player.uuid, Game.Team.SEEKER) + plugin.game.loadSeeker(player) + if (plugin.game.status == Game.Status.SEEKING) plugin.game.giveSeekerItems(player) +} + +fun becomeSpectator(plugin: Khs, player: Player) { + plugin.game.setTeam(player.uuid, Game.Team.SPECTATOR) + plugin.game.loadSpectator(player) +} + +fun dieInGame(plugin: Khs, player: Player) { + val team = plugin.game.getTeam(player.uuid) + if (team == null || team == Game.Team.SPECTATOR) return + if (plugin.game.status != Game.Status.SEEKING) return + player.health = 0.1 +} + +fun createDebugMenu(plugin: Khs): Inventory? { + val inv = plugin.shim.createInventory(DEBUG_TITLE, 9u) ?: return null + val items = listOf(BECOME_HIDER, BECOME_SEEKER, BECOME_SPECTATOR, DIE_IN_GAME, REVEAL_DISGUISE) + items + .map { plugin.shim.parseItem(it) } + .filterNotNull() + .withIndex() + .forEach { (i, item) -> inv.set(i.toUInt(), item) } + return inv +} diff --git a/core/src/inv/Teleport.kt b/core/src/inv/Teleport.kt new file mode 100644 index 0000000..2e5ddbb --- /dev/null +++ b/core/src/inv/Teleport.kt @@ -0,0 +1,58 @@ +package cat.freya.khs.inv + +import cat.freya.khs.Khs +import cat.freya.khs.config.ItemConfig +import cat.freya.khs.game.Game +import cat.freya.khs.player.Inventory +import cat.freya.khs.player.Player +import cat.freya.khs.world.Item + +const val TELEPORT_TITLE = "Teleport to players" + +fun createPageItem(plugin: Khs, page: UInt): Item? { + val config = ItemConfig("Page ${page + 1u}", "ENCHANTED_BOOK") + return plugin.shim.parseItem(config) +} + +fun createPlayerItem(plugin: Khs, player: Player): Item? { + val team = plugin.game.getTeam(player.uuid) ?: return null + val teamName = + when (team) { + Game.Team.HIDER -> plugin.locale.game.team.hider + Game.Team.SEEKER -> plugin.locale.game.team.seeker + else -> "" + } + val config = + ItemConfig( + name = player.name, + material = "PLAYER_HEAD", + owner = player.name, + lore = listOf(teamName), + ) + return plugin.shim.parseItem(config) +} + +fun createTeleportMenu(plugin: Khs, page: UInt): Inventory? { + val pageSize = 7u + val offset = pageSize * page + + // make items + val players = (plugin.game.seekerPlayers + plugin.game.hiderPlayers) + val items = + players.drop(offset.toInt()).take(pageSize.toInt()).mapNotNull { + createPlayerItem(plugin, it) + } + val prev = if (page > 0u) createPageItem(plugin, page - 1u) else null + val next = + if (players.size.toUInt() > offset + pageSize) createPageItem(plugin, page + 1u) else null + + // create inv + val inv = plugin.shim.createInventory(TELEPORT_TITLE, 9u) ?: return null + for ((i, item) in items.withIndex()) { + inv.set(i.toUInt() + 1u, item) + } + if (prev != null) inv.set(0u, prev) + if (next != null) inv.set(8u, next) + + return inv +} diff --git a/core/src/player/Inventory.kt b/core/src/player/Inventory.kt new file mode 100644 index 0000000..a055e04 --- /dev/null +++ b/core/src/player/Inventory.kt @@ -0,0 +1,30 @@ +package cat.freya.khs.player + +import cat.freya.khs.world.Item + +// Inventory wrapper +interface Inventory { + val title: String? + + // update inventory items + fun get(index: UInt): Item? + + fun set(index: UInt, item: Item) + + fun remove(item: Item) + + // view into entire inventory + var contents: List<Item?> + + // removes all items + fun clear() +} + +// Player inventory wrapper +interface PlayerInventory : Inventory { + // update armor + var helmet: Item? + var chestplate: Item? + var leggings: Item? + var boots: Item? +} diff --git a/core/src/player/Player.kt b/core/src/player/Player.kt new file mode 100644 index 0000000..799bbff --- /dev/null +++ b/core/src/player/Player.kt @@ -0,0 +1,85 @@ +package cat.freya.khs.player + +import cat.freya.khs.world.Effect +import cat.freya.khs.world.Location +import cat.freya.khs.world.Position +import cat.freya.khs.world.World +import java.util.UUID + +// Player wrapper +interface Player { + // Metadata + val uuid: UUID + val name: String + + // Position + val location: Location + val world: World? + + // Stats + var health: Double + var hunger: UInt + + fun heal() + + // Flight + var allowFlight: Boolean + var flying: Boolean + + // Movement + fun teleport(position: Position) + + fun teleport(location: Location) + + fun sendToServer(server: String) + + // Inventory + val inventory: PlayerInventory + + fun showInventory(inv: Inventory) + + fun closeInventory() + + // Potions + fun clearEffects() + + fun giveEffect(effect: Effect) + + fun setSpeed(amplifier: UInt) + + fun setGlow(target: Player, glow: Boolean) + + fun setHidden(target: Player, hidden: Boolean) + + // Messaging + fun message(message: String) + + fun actionBar(message: String) + + fun title(title: String, subTitle: String) + + fun playSound(sound: String, volume: Double, pitch: Double) + + // Block Hunt + fun isDisguised(): Boolean + + fun disguise(material: String) + + fun revealDisguise() + + enum class GameMode { + CREATIVE, + SURVIVAL, + ADVENTURE, + SPECTATOR, + } + + // Other + fun hasPermission(permission: String): Boolean + + fun setGameMode(gameMode: GameMode) + + fun hideBoards() + + fun taunt() +} diff --git a/core/src/world/Item.kt b/core/src/world/Item.kt new file mode 100644 index 0000000..d407c1a --- /dev/null +++ b/core/src/world/Item.kt @@ -0,0 +1,23 @@ +package cat.freya.khs.world + +import cat.freya.khs.config.EffectConfig +import cat.freya.khs.config.ItemConfig + +interface Item { + val name: String? + val material: String + val config: ItemConfig + + fun clone(): Item + + fun similar(config: ItemConfig): Boolean + + fun similar(material: String): Boolean +} + +interface Effect { + val name: String? + val config: EffectConfig + + fun clone(): Effect +} diff --git a/core/src/world/Location.kt b/core/src/world/Location.kt new file mode 100644 index 0000000..384c859 --- /dev/null +++ b/core/src/world/Location.kt @@ -0,0 +1,35 @@ +package cat.freya.khs.world + +import cat.freya.khs.Khs +import cat.freya.khs.player.Player + +data class Location( + var x: Double = 0.0, + var y: Double = 0.0, + var z: Double = 0.0, + var worldName: String = "world", +) { + /// Returns the position from this location + var position: Position + get() = Position(this.x, this.y, this.z) + set(new: Position) { + this.x = new.x + this.y = new.y + this.z = new.z + } + + /// Returns the world associated with this location + fun getWorld(khs: Khs): World? { + return khs.shim.getWorld(this.worldName) + } + + fun distance(other: Location): Double { + if (this.worldName != other.worldName) return Double.POSITIVE_INFINITY + + return position.distance(other.position) + } + + fun teleport(player: Player) { + player.teleport(this) + } +} diff --git a/core/src/world/Position.kt b/core/src/world/Position.kt new file mode 100644 index 0000000..daea3ab --- /dev/null +++ b/core/src/world/Position.kt @@ -0,0 +1,54 @@ +package cat.freya.khs.world + +import cat.freya.khs.config.LegacyPosition +import cat.freya.khs.player.Player +import kotlin.math.pow +import kotlin.math.sqrt + +data class Position(var x: Double = 0.0, var y: Double = 0.0, var z: Double = 0.0) { + + /// Create a new position of self + offset + fun move(offset: Position): Position { + return Position(this.x + offset.x, this.y + offset.y, this.z + offset.z) + } + + /// Translate self by offset + fun moveSelf(offset: Position) { + this.x += offset.x + this.y += offset.y + this.z += offset.z + } + + /// Create a new position of self.x + offset + fun moveX(offset: Double): Position { + return this.move(Position(offset, 0.0, 0.0)) + } + + /// Create a new position of self.y + offset + fun moveY(offset: Double): Position { + return this.move(Position(0.0, offset, 0.0)) + } + + /// Create a new position of self.z + offset + fun moveZ(offset: Double): Position { + return this.move(Position(0.0, 0.0, offset)) + } + + fun distance(other: Position): Double { + val dx = this.x - other.x + val dy = this.y - other.y + val dz = this.z - other.z + val distanceSquared = dx.pow(2) + dy.pow(2) + dz.pow(2) + return sqrt(distanceSquared) + } + + fun teleport(player: Player) { + player.teleport(this) + } + + fun withWorld(worldName: String): Location { + return Location(this.x, this.y, this.z, worldName) + } + + fun toLegacy(): LegacyPosition = LegacyPosition(x, y, z, null) +} diff --git a/core/src/world/World.kt b/core/src/world/World.kt new file mode 100644 index 0000000..4ab575f --- /dev/null +++ b/core/src/world/World.kt @@ -0,0 +1,60 @@ +package cat.freya.khs.world + +import java.io.File + +interface World { + /// The name of the minecraft world + val name: String + val type: Type + + enum class Type { + NORMAL, + FLAT, + NETHER, + END, + UNKNOWN, + } + + // The extent of the height + val minY: Int + val maxY: Int + + val spawn: Position + + /// Wrapper for world border values + interface Border { + val x: Double + val z: Double + val size: Double + + fun move(newX: Double, newZ: Double, newSize: ULong, delay: ULong) + + fun move(newSize: ULong, delay: ULong) + } + + // World border + val border: Border + + interface Loader { + val name: String + val world: World? + + // Returns the world folder + val dir: File + + // Returns the map save folder + val saveDir: File + + // Returns the temp map save folder + val tempSaveDir: File + + fun load() + + fun unload() + + fun rollback() + } + + // Returns the world loader + val loader: Loader +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..10290dd --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1773821835, + "narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..1cbfacf --- /dev/null +++ b/flake.nix @@ -0,0 +1,45 @@ +{ + description = "khs nix flake"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { + nixpkgs, + flake-utils, + ... + }: let + supportedSystems = let + inherit (flake-utils.lib) system; + in [ + system.aarch64-linux + system.aarch64-darwin + system.x86_64-linux + ]; + in + flake-utils.lib.eachSystem supportedSystems (system: let + pkgs = import nixpkgs {inherit system;}; + in { + devShell = pkgs.mkShell { + packages = with pkgs; [ + (gradle.override { + jdk = openjdk8; + }) + kotlin + kotlin-language-server + ]; + + shellHook = '' + export JAVA_HOME=${pkgs.openjdk8}/lib/openjdk + + ktfmt() { + find . -name "*.kt" | xargs ${pkgs.ktfmt}/bin/ktfmt --kotlinlang-style "$@" + } + ''; + }; + + formatter = pkgs.alejandra; + }); +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..c43e970 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +org.gradle.problems.report=false diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..cc6c66b --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,2 @@ +rootProject.name = "Kenshins Hide and Seek" +include("core", "bukkit") |